From 359caca6729f7ac76e65b96a4a192dd7e3c75502 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 4 Jul 2026 22:58:58 +0800 Subject: [PATCH] feat(input): support file mentions --- server/files.py | 144 +- static/src/App.vue | 1 + static/src/components/chat/ChatArea.vue | 36 +- static/src/components/input/FileAtMenu.vue | 234 +++ static/src/components/input/InputComposer.vue | 570 +++++- .../styles/components/chat/_chat-area.scss | 3 +- .../styles/components/input/_composer.scss | 1773 +++++++++-------- 7 files changed, 1785 insertions(+), 976 deletions(-) create mode 100644 static/src/components/input/FileAtMenu.vue diff --git a/server/files.py b/server/files.py index f26a254..414b17b 100644 --- a/server/files.py +++ b/server/files.py @@ -1,10 +1,11 @@ """文件与GUI文件管理相关路由。""" from __future__ import annotations import os +import re import zipfile from io import BytesIO from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, List, Optional, Tuple from flask import Blueprint, jsonify, request, send_file from werkzeug.utils import secure_filename @@ -311,4 +312,145 @@ def gui_text_entry(terminal, workspace, username): return jsonify({"success": False, "error": str(exc)}), 400 +def _score_file_match(path: str, query: str) -> Optional[Tuple[int, ...]]: + """为 @文件 搜索计算匹配分数;分数越低越靠前,None 表示不匹配。""" + path_lower = path.lower() + query_lower = query.lower() + + if path_lower.startswith(query_lower): + return (0, len(path)) + + query_parts = query_lower.split('/') + path_parts = path_lower.split('/') + + if len(query_parts) > 1 and len(path_parts) >= len(query_parts): + matched = True + for i, q in enumerate(query_parts): + p = path_parts[i] + if not p.startswith(q) and q not in p: + matched = False + break + if matched: + return (1, len(path)) + + name = path_parts[-1] if path_parts else path_lower + if name.startswith(query_lower): + return (2, len(path)) + + if query_lower in name: + return (3, len(path)) + + if query_lower in path_lower: + return (4, len(path)) + + return None + + +def _scan_project_entries(project_path: Path, max_depth: int = 6) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """扫描项目目录,返回文件和文件夹列表(包含隐藏目录)。""" + files: List[Dict[str, Any]] = [] + folders: List[Dict[str, Any]] = [] + + for root, dirs, filenames in os.walk(project_path): + rel_root = Path(root).relative_to(project_path) + level = len(rel_root.parts) if str(rel_root) != '.' else 0 + if level > max_depth: + del dirs[:] + continue + + for d in list(dirs): + dir_path = rel_root / d if str(rel_root) != '.' else Path(d) + folders.append({ + "name": d, + "path": str(dir_path).replace('\\', '/'), + "type": "directory" + }) + + for f in filenames: + file_path = rel_root / f if str(rel_root) != '.' else Path(f) + ext = Path(f).suffix.lower() + files.append({ + "name": f, + "path": str(file_path).replace('\\', '/'), + "type": "file", + "extension": ext + }) + + return files, folders + + +def _scan_root_entries(project_path: Path) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """只扫描项目根目录的直接子项(包含隐藏目录)。""" + files: List[Dict[str, Any]] = [] + folders: List[Dict[str, Any]] = [] + if not project_path.exists() or not project_path.is_dir(): + return files, folders + for entry in sorted(project_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())): + if entry.is_dir(): + folders.append({ + "name": entry.name, + "path": entry.name, + "type": "directory" + }) + elif entry.is_file(): + files.append({ + "name": entry.name, + "path": entry.name, + "type": "file", + "extension": entry.suffix.lower() + }) + return files, folders + + +@files_bp.route('/api/project/files/search', methods=['GET']) +@api_login_required +@with_terminal +def search_project_files(terminal, workspace, username): + """为 @文件 功能提供项目内文件/文件夹搜索(包含隐藏目录)。""" + policy = resolve_admin_policy(get_current_user_record()) + if policy.get("ui_blocks", {}).get("collapse_workspace") or policy.get("ui_blocks", {}).get("block_file_manager"): + return jsonify({"success": False, "error": "文件浏览已被管理员禁用"}), 403 + + query = str(request.args.get('q') or '').strip() + + try: + project_path = Path(getattr(workspace, 'project_path', '') or '').expanduser().resolve() + if not project_path.exists(): + return jsonify({"success": False, "error": "项目路径不存在"}), 400 + except Exception as exc: + return jsonify({"success": False, "error": f"项目路径无效: {exc}"}), 400 + + try: + files, folders = _scan_project_entries(project_path, max_depth=6) + except Exception as exc: + return jsonify({"success": False, "error": f"扫描项目失败: {exc}"}), 500 + + if not query: + root_files, root_folders = _scan_root_entries(project_path) + root_items = root_folders + root_files + return jsonify({ + "success": True, + "data": { + "items": root_items[:50], + "total": len(root_items) + } + }) + + scored: List[Tuple[Tuple[int, ...], Dict[str, Any]]] = [] + for entry in files + folders: + score = _score_file_match(entry["path"], query) + if score is not None: + scored.append((score, entry)) + + scored.sort(key=lambda x: (x[0], x[1]["path"].lower())) + limit = max(10, min(100, int(request.args.get('limit') or 50))) + return jsonify({ + "success": True, + "data": { + "items": [entry for _, entry in scored[:limit]], + "total": len(scored) + } + }) + + __all__ = ["files_bp"] diff --git a/static/src/App.vue b/static/src/App.vue index 3ae5834..c5bcfbc 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -295,6 +295,7 @@ :block-tool-toggle="policyUiBlocks.block_tool_toggle" :block-realtime-terminal="policyUiBlocks.block_realtime_terminal" :terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0" + :host-mode="versioningHostMode" :block-focus-panel="policyUiBlocks.block_focus_panel" :block-token-panel="policyUiBlocks.block_token_panel" :block-compress-conversation="policyUiBlocks.block_compress_conversation" diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index fdf408e..181e476 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -933,16 +933,35 @@ const escapeUserHtml = (value: string): string => .replace(/'/g, '''); const USER_SKILL_LINK_RE = /\[\$([^\]\n]+)\]\(([^)\n]*\/\.agents\/skills\/[^)\n]+\/SKILL\.md)\)/g; +const USER_FILE_LINK_RE = /\[([^\]\n]+)\]\(file:\/\/([^)\n]+)\)/g; +const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/; +const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/; +const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/; +const RENDERABLE_ACTION_TYPES = new Set([ + 'thinking', + 'text', + 'tool', + 'append', + 'append_payload', + 'system' +]); function renderUserMessageContent(content: string): string { const source = String(content || ''); - USER_SKILL_LINK_RE.lastIndex = 0; + const combinedRe = new RegExp( + `(?:${USER_SKILL_LINK_RE.source})|(?:${USER_FILE_LINK_RE.source})`, + 'g' + ); let html = ''; let cursor = 0; let match: RegExpExecArray | null; - while ((match = USER_SKILL_LINK_RE.exec(source))) { + while ((match = combinedRe.exec(source))) { html += escapeUserHtml(source.slice(cursor, match.index)); - html += ` ${escapeUserHtml(match[1] || '')} `; + if (match[1] !== undefined) { + html += ` ${escapeUserHtml(match[1] || '')} `; + } else if (match[3] !== undefined) { + html += ` ${escapeUserHtml(match[3] || '')} `; + } cursor = match.index + match[0].length; } html += escapeUserHtml(source.slice(cursor)); @@ -1040,17 +1059,6 @@ watch( statusAvatarActiveIndex.value = 0; } ); -const SUB_AGENT_DONE_LABEL_RE = /^子智能体\d+\s*任务完成$/; -const SUB_AGENT_DONE_PREFIX_RE = /^(?:✅\s*)?子智能体\s*#?\s*(\d+)\s*任务摘要[::]/; -const BG_RUN_COMMAND_DONE_LABEL_RE = /^(?:\[)?后台\s*run_command\s*完成(?:\])?$/; -const RENDERABLE_ACTION_TYPES = new Set([ - 'thinking', - 'text', - 'tool', - 'append', - 'append_payload', - 'system' -]); const nowMs = ref(Date.now()); let timerHandle: number | null = null; const debugLoggedSystemActionKeys = new Set(); diff --git a/static/src/components/input/FileAtMenu.vue b/static/src/components/input/FileAtMenu.vue new file mode 100644 index 0000000..eabf456 --- /dev/null +++ b/static/src/components/input/FileAtMenu.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 3154f29..adc6059 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -47,11 +47,7 @@ role="listbox" :aria-label="slashMenuAriaLabel" > -
+
-
+
+ -
+
+
@@ -108,7 +112,9 @@
@@ -227,7 +239,11 @@ v-if="isVoiceInputSupported" type="button" class="stadium-btn voice-btn" - :class="{ 'voice-btn--recording': isRecording, 'voice-btn--disabled': voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready' }" + :class="{ + 'voice-btn--recording': isRecording, + 'voice-btn--disabled': + voiceModelStatus === 'downloading' || voiceModelStatus === 'model_not_ready' + }" @click="toggleVoiceRecording" :disabled="!isConnected || inputLocked" :title="voiceButtonTitle" @@ -264,9 +280,9 @@ @click="$emit('send-or-stop')" :disabled="sendButtonDisabled" > - - - + + +
@@ -445,6 +461,7 @@ import Mention from '@tiptap/extension-mention'; import Placeholder from '@tiptap/extension-placeholder'; import { TextSelection } from 'prosemirror-state'; import QuickMenu from '@/components/input/QuickMenu.vue'; +import FileAtMenu, { type FileAtItem } from '@/components/input/FileAtMenu.vue'; import RollingNumber from '@/components/input/RollingNumber.vue'; import { useInputStore } from '@/stores/input'; import { usePersonalizationStore } from '@/stores/personalization'; @@ -561,6 +578,7 @@ const props = defineProps<{ goalProgress?: Record | null; hasPendingRuntimeGuidance?: boolean; terminalCount?: number; + hostMode?: boolean; }>(); const inputStore = useInputStore(); @@ -572,7 +590,14 @@ const stadiumInput = ref(null); const fileUploadInput = ref(null); const baselineComposerVisualHeight = ref(0); type SkillItem = { name: string; description: string; path: string }; -type SlashMenuMode = 'root' | 'skills' | 'theme' | 'permission' | 'execution' | 'model' | 'run-mode'; +type SlashMenuMode = + | 'root' + | 'skills' + | 'theme' + | 'permission' + | 'execution' + | 'model' + | 'run-mode'; type SlashMenuItem = { id: string; label: string; @@ -603,15 +628,27 @@ let slashAnimating = false; let lastSlashAnimatedScroll: number | null = null; let lastSlashAnimationEndTime = 0; +const fileAtMenuRef = ref | null>(null); +const fileAtOpen = ref(false); +const fileAtQuery = ref(null); +const fileAtActiveIndex = ref(0); +const fileAtItems = ref([]); +const fileAtLoading = ref(false); +const fileAtMenuStyle = ref>({}); +const fileAtPickerActive = ref(false); +let fileAtSearchTimer: number | null = null; + const slashHighlightStyle = computed(() => { if (slashManualScrolled.value) return { display: 'none' }; const isFirst = skillSlashActiveIndex.value === 0 && skillSlashList.value?.scrollTop === 0; return { top: `${slashHighlightTop.value}px`, - ...(isFirst ? { - borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))', - borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))', - } : {}), + ...(isFirst + ? { + borderTopLeftRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))', + borderTopRightRadius: 'calc(var(--skill-slash-radius) - var(--skill-slash-gap))' + } + : {}) }; }); @@ -715,9 +752,10 @@ const projectGitSummaryForRender = computed(() => { }); const floatingStatusVisible = computed(() => { - if (skillSlashMenuOpen.value || props.quickMenuOpen) return false; + if (skillSlashMenuOpen.value || fileAtOpen.value || props.quickMenuOpen) return false; // 消息队列(预输入/引导消息)存在时隐藏git状态栏,与 / 菜单出现时逻辑相同 - if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) return false; + if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) + return false; return ( !!projectGitSummaryForRender.value || !!props.goalRunning || @@ -782,6 +820,25 @@ const findSlashToken = () => { }; }; +const findAtToken = () => { + const instance = editor.value; + if (!instance) return null; + const { state } = instance; + const { $from } = state.selection; + const beforeInLine = $from.parent.textBetween(0, $from.parentOffset, '\n', '\n'); + const match = /(^|[ \n])@([^\s@]*)$/.exec(beforeInLine); + if (!match) return null; + const query = match[2] || ''; + const start = $from.pos - query.length - 1; + const end = $from.pos; + return { + start, + end, + query, + charStart: start + }; +}; + const closeSlashMenu = () => { if (slashAnimId !== null) { cancelAnimationFrame(slashAnimId); @@ -799,6 +856,192 @@ const closeSlashMenu = () => { slashManualScrolled.value = false; }; +const closeFileAtMenu = () => { + fileAtOpen.value = false; + fileAtQuery.value = null; + fileAtActiveIndex.value = 0; + fileAtItems.value = []; + fileAtLoading.value = false; + fileAtMenuStyle.value = {}; + if (fileAtSearchTimer !== null) { + window.clearTimeout(fileAtSearchTimer); + fileAtSearchTimer = null; + } +}; + +const updateFileAtMenuPosition = (tokenEnd: number) => { + nextTick(() => { + const instance = editor.value; + if (!instance) return; + try { + const coords = instance.view.coordsAtPos(tokenEnd); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const menuWidth = 280; + const menuHeight = 250; + const gap = 4; + + // 默认:菜单左下角贴着 @ 后面文字的右上角 + let left = coords.right; + let top = coords.top - menuHeight; + + if (left + menuWidth > viewportWidth - gap) { + left = Math.max(gap, viewportWidth - menuWidth - gap); + } + + if (top < gap) { + top = coords.bottom; + } + if (top + menuHeight > viewportHeight - gap) { + top = Math.max(gap, viewportHeight - menuHeight - gap); + } + + fileAtMenuStyle.value = { + left: `${left}px`, + top: `${top}px`, + width: `${menuWidth}px`, + maxHeight: `${menuHeight}px` + }; + } catch { + fileAtMenuStyle.value = {}; + } + }); +}; + +const isImageFile = (path: string): boolean => { + if (!path) return false; + const ext = path.split('.').pop()?.toLowerCase() || ''; + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext); +}; + +const fetchProjectFileSearch = async (query: string) => { + fileAtLoading.value = true; + try { + const url = `/api/project/files/search?q=${encodeURIComponent(query)}&limit=50`; + const response = await fetch(url); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + fileAtItems.value = []; + return; + } + fileAtItems.value = Array.isArray(data?.data?.items) ? data.data.items : []; + } catch (error) { + console.warn('[FileAt] 搜索文件失败:', error); + fileAtItems.value = []; + } finally { + fileAtLoading.value = false; + } +}; + +const scheduleFileAtSearch = (query: string) => { + if (fileAtSearchTimer !== null) { + window.clearTimeout(fileAtSearchTimer); + } + fileAtSearchTimer = window.setTimeout(() => { + void fetchProjectFileSearch(query); + }, 120); +}; + +const refreshFileAtState = () => { + const token = findAtToken(); + if (!token || !props.isConnected || props.inputLocked) { + closeFileAtMenu(); + return; + } + closeProjectGitMenu(); + const queryChanged = fileAtQuery.value !== token.query; + fileAtOpen.value = true; + if (queryChanged || fileAtItems.value.length === 0) { + fileAtActiveIndex.value = 0; + fileAtQuery.value = token.query; + scheduleFileAtSearch(token.query); + } + updateFileAtMenuPosition(token.end); +}; + +const insertFileAtMention = (item: FileAtItem) => { + const token = findAtToken(); + const instance = editor.value; + if (!token || !instance) return; + const { state, view, schema } = instance; + const mentionType = schema.nodes.mention; + if (!mentionType) return; + const mentionNode = mentionType.create({ + id: `file://${item.path}`, + label: item.name + }); + let tr = state.tr.delete(token.start, token.end); + tr = tr.insert(token.start, mentionNode); + const afterMention = token.start + mentionNode.nodeSize; + tr = tr.insertText(' ', afterMention); + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1))); + view.dispatch(tr); + view.focus(); + closeFileAtMenu(); + nextTick(() => { + adjustTextareaSize(); + }); +}; + +const openFilePickerAndReference = () => { + closeFileAtMenu(); + if (!props.hostMode) { + return; + } + fileAtPickerActive.value = true; + fileUploadInput.value?.click(); +}; + +const selectFileAtItemByIndex = (index: number) => { + const hasPicker = !!props.hostMode; + if (hasPicker && index === 0) { + void openFilePickerAndReference(); + return; + } + const fileIndex = hasPicker ? index - 1 : index; + const item = fileAtItems.value[fileIndex]; + if (!item) return; + if (item.type === 'file' && isImageFile(item.path)) { + closeFileAtMenu(); + emit('pick-images'); + return; + } + insertFileAtMention(item); +}; + +const handleFileAtKeydown = (event: KeyboardEvent): boolean => { + if (!fileAtOpen.value) return false; + const hasPicker = !!props.hostMode; + const total = (hasPicker ? 1 : 0) + fileAtItems.value.length; + if (total <= 0) return false; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + fileAtMenuRef.value?.setManualScroll(false); + fileAtActiveIndex.value = (fileAtActiveIndex.value + 1) % total; + fileAtMenuRef.value?.focusActive(); + return true; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + fileAtMenuRef.value?.setManualScroll(false); + fileAtActiveIndex.value = (fileAtActiveIndex.value - 1 + total) % total; + fileAtMenuRef.value?.focusActive(); + return true; + } + if (event.key === 'Enter') { + event.preventDefault(); + selectFileAtItemByIndex(fileAtActiveIndex.value); + return true; + } + if (event.key === 'Escape') { + event.preventDefault(); + closeFileAtMenu(); + return true; + } + return false; +}; + const deleteSlashToken = () => { const token = findSlashToken(); const instance = editor.value; @@ -822,12 +1065,15 @@ const scrollSkillSlashSelectionIntoMiddle = () => { const styles = getComputedStyle(list); const cssRowHeight = parseFloat(styles.getPropertyValue('--skill-slash-row-height')); const cssGap = parseFloat(styles.getPropertyValue('--skill-slash-gap')); - const rowHeight = (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) + - (Number.isFinite(cssGap) ? cssGap : 5); + const rowHeight = + (Number.isFinite(cssRowHeight) ? cssRowHeight : 38) + (Number.isFinite(cssGap) ? cssGap : 5); const visibleRows = Math.max(1, Math.floor(list.clientHeight / rowHeight) || 5); const middleOffset = Math.floor(visibleRows / 2); const maxScroll = Math.max(0, list.scrollHeight - list.clientHeight); - const target = Math.max(0, Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight)); + const target = Math.max( + 0, + Math.min(maxScroll, (skillSlashActiveIndex.value - middleOffset) * rowHeight) + ); animateSlash(list, target); }); }; @@ -836,7 +1082,11 @@ const filteredSkills = computed(() => { const query = skillSlashQuery.value.trim().toLowerCase(); const list = Array.isArray(availableSkills.value) ? availableSkills.value : []; const filtered = query - ? list.filter((skill) => String(skill.name || '').toLowerCase().includes(query)) + ? list.filter((skill) => + String(skill.name || '') + .toLowerCase() + .includes(query) + ) : list; return filtered.slice(0, 100); }); @@ -938,7 +1188,11 @@ const rootSlashMenuItems = computed(() => { { id: 'goal', label: '目标模式', - description: props.goalRunning ? '目标模式运行中' : props.goalModeArmed ? '目标模式已就绪' : '切换目标模式', + description: props.goalRunning + ? '目标模式运行中' + : props.goalModeArmed + ? '目标模式已就绪' + : '切换目标模式', disabled: !props.isConnected, action: () => emit('toggle-goal-mode') }, @@ -979,13 +1233,15 @@ const rootSlashMenuItems = computed(() => { mode: 'permission' }, ...(props.executionModeEnabled - ? [{ - id: 'execution', - label: '执行环境', - description: '切换 sandbox / direct', - disabled: !props.isConnected || props.streamingMessage, - mode: 'execution' - } as SlashMenuItem] + ? [ + { + id: 'execution', + label: '执行环境', + description: '切换 sandbox / direct', + disabled: !props.isConnected || props.streamingMessage, + mode: 'execution' + } as SlashMenuItem + ] : []), { id: 'versioning', @@ -997,7 +1253,8 @@ const rootSlashMenuItems = computed(() => { { id: 'git-bar', label: 'Git 状态栏', - description: (personalizationStore?.form?.show_git_status_bar !== false) ? '当前:显示' : '当前:隐藏', + description: + personalizationStore?.form?.show_git_status_bar !== false ? '当前:显示' : '当前:隐藏', action: () => { const currentValue = personalizationStore?.form?.show_git_status_bar !== false; const newValue = !currentValue; @@ -1066,7 +1323,7 @@ const themeSlashMenuItems = computed(() => { }); const permissionSlashMenuItems = computed(() => { - const options = (props.permissionOptions || []); + const options = props.permissionOptions || []; const current = String(props.currentPermissionMode || ''); return options.map((opt) => ({ id: `perm:${opt.value}`, @@ -1077,7 +1334,7 @@ const permissionSlashMenuItems = computed(() => { }); const executionSlashMenuItems = computed(() => { - const options = (props.executionModeOptions || []); + const options = props.executionModeOptions || []; const current = String(props.currentExecutionMode || ''); return options.map((opt) => ({ id: `exec:${opt.value}`, @@ -1156,14 +1413,17 @@ const slashMenuAriaLabel = computed(() => { }); const slashMenuEmptyText = computed(() => { - if (slashMenuMode.value === 'skills') return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; + if (slashMenuMode.value === 'skills') + return skillsLoading.value ? '正在加载 skills...' : '没有匹配的 skill'; if (slashMenuMode.value === 'permission') return '无可用权限模式'; if (slashMenuMode.value === 'execution') return '无可用执行环境'; if (slashMenuMode.value === 'model') return '无可用模型'; return '没有匹配的指令'; }); -const skillSlashMenuOpen = computed(() => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0)); +const skillSlashMenuOpen = computed( + () => skillSlashOpen.value && (skillsLoading.value || activeSlashMenuItems.value.length >= 0) +); const loadSkills = async () => { if (skillsLoaded.value || skillsLoading.value) { @@ -1210,7 +1470,10 @@ const refreshSkillSlashState = () => { if (slashMenuMode.value === 'skills') { void loadSkills(); } - skillSlashActiveIndex.value = Math.min(skillSlashActiveIndex.value, Math.max(0, activeSlashMenuItems.value.length - 1)); + skillSlashActiveIndex.value = Math.min( + skillSlashActiveIndex.value, + Math.max(0, activeSlashMenuItems.value.length - 1) + ); scrollSkillSlashSelectionIntoMiddle(); }; @@ -1327,10 +1590,16 @@ const handleSlashMenuKeydown = (event: KeyboardEvent): boolean => { }; const onKeydown = (event: KeyboardEvent): boolean => { + if (handleFileAtKeydown(event)) { + return true; + } if (handleSlashMenuKeydown(event)) { return true; } - requestAnimationFrame(refreshSkillSlashState); + requestAnimationFrame(() => { + refreshSkillSlashState(); + refreshFileAtState(); + }); return false; }; @@ -1338,6 +1607,7 @@ const onInputBlur = () => { emit('input-blur'); window.setTimeout(() => { closeSlashMenu(); + closeFileAtMenu(); }, 120); }; @@ -1365,8 +1635,15 @@ const editorJsonToMessage = (json?: JSONContent | null) => { if (node.type === 'text') return node.text || ''; if (node.type === 'mention') { const attrs = node.attrs || {}; - const name = String(attrs.label || attrs.id || '').trim(); - const path = String(attrs.path || attrs.id || '').trim(); + const id = String(attrs.id || ''); + const label = String(attrs.label || id).trim(); + const isFile = id.startsWith('file://'); + if (isFile) { + const filePath = id.slice(7); + return label && filePath ? `[${label}](file://${filePath})` : label; + } + const name = label; + const path = String(attrs.path || id).trim(); if (name && path && !skillRefs.some((item) => item.path === path)) { skillRefs.push({ name, path }); } @@ -1376,7 +1653,10 @@ const editorJsonToMessage = (json?: JSONContent | null) => { return Array.isArray(node.content) ? node.content.map(readInline).join('') : ''; }; const blocks = Array.isArray(json?.content) ? json.content : []; - const message = blocks.map((block) => readInline(block)).join('\n').trim(); + const message = blocks + .map((block) => readInline(block)) + .join('\n') + .trim(); return { message, skillRefs }; }; @@ -1406,12 +1686,19 @@ const editor = useEditor({ }, renderHTML({ node, HTMLAttributes }) { const attrs = HTMLAttributes || {}; + const id = String(node.attrs.id || ''); + const isFile = id.startsWith('file://'); + const className = isFile + ? `${attrs.class || ''} file-md-link`.trim() + : `${attrs.class || ''} skill-md-link`.trim(); + const dataAttr = isFile ? 'data-file-path' : 'data-skill-path'; + const dataValue = isFile ? id.slice(7) : node.attrs.path || node.attrs.id || ''; return [ 'span', { ...attrs, - class: `${attrs.class || ''} skill-md-link`.trim(), - 'data-skill-path': node.attrs.path || node.attrs.id || '' + class: className, + [dataAttr]: dataValue }, String(node.attrs.label || node.attrs.id || '') ]; @@ -1442,6 +1729,7 @@ const editor = useEditor({ nextTick(() => { adjustTextareaSize(); refreshSkillSlashState(); + refreshFileAtState(); }); }, onFocus() { @@ -1492,12 +1780,19 @@ const setupVoiceBridgeCallbacks = () => { return; } bridge?.debugLog('__onVoiceResult: editor.value 存在,准备插入文字'); - bridge?.debugLog('__onVoiceResult: voiceAnchorPos=' + voiceAnchorPos + ' lastVoiceTextLength=' + lastVoiceTextLength); + bridge?.debugLog( + '__onVoiceResult: voiceAnchorPos=' + + voiceAnchorPos + + ' lastVoiceTextLength=' + + lastVoiceTextLength + ); const { state, view } = ed; const docSize = state.doc.content.size; const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize)); const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize); - bridge?.debugLog('__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd); + bridge?.debugLog( + '__onVoiceResult: docSize=' + docSize + ' safeAnchor=' + safeAnchor + ' oldEnd=' + oldEnd + ); try { let tr = state.tr; if (oldEnd > safeAnchor) tr = tr.delete(safeAnchor, oldEnd); @@ -1575,7 +1870,7 @@ const isWebSpeechSupported = computed(() => { const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => { if (typeof window === 'undefined') return null; const Ctor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; - console.log('[VoiceInput] 构造函数:', Ctor ? (Ctor.name || 'webkitSpeechRecognition') : 'null'); + console.log('[VoiceInput] 构造函数:', Ctor ? Ctor.name || 'webkitSpeechRecognition' : 'null'); return Ctor; }; @@ -1623,7 +1918,11 @@ const initSpeechRecognition = () => { let fullText = ''; for (let i = 0; i < event.results.length; i++) { const r = event.results[i]; - console.log(`[VoiceInput] result[${i}]:`, { transcript: r[0]?.transcript, isFinal: r.isFinal, confidence: r[0]?.confidence }); + console.log(`[VoiceInput] result[${i}]:`, { + transcript: r[0]?.transcript, + isFinal: r.isFinal, + confidence: r[0]?.confidence + }); fullText += r[0].transcript; } @@ -1806,7 +2105,10 @@ const keepRuntimeQueueTransitionCollapsed = () => { const cancelAnim = (el: HTMLElement) => { const timer = runtimeQueueFallbackTimers.get(el); - if (timer) { clearTimeout(timer); runtimeQueueFallbackTimers.delete(el); } + if (timer) { + clearTimeout(timer); + runtimeQueueFallbackTimers.delete(el); + } el.style.removeProperty('transition'); el.style.removeProperty('transform'); el.style.removeProperty('opacity'); @@ -1831,14 +2133,18 @@ const scheduleTransition = ( }; // Apply from state immediately (no transition) el.style.transition = 'none'; - Object.entries(from).forEach(([k, v]) => { el.style.setProperty(k, v); }); + Object.entries(from).forEach(([k, v]) => { + el.style.setProperty(k, v); + }); // Defer to next frame: enable transition + apply to state // All scheduleTransition calls in the same microtask share this RAF requestAnimationFrame(() => { el.style.transition = props .map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`) .join(', '); - Object.entries(to).forEach(([k, v]) => { el.style.setProperty(k, v); }); + Object.entries(to).forEach(([k, v]) => { + el.style.setProperty(k, v); + }); const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40); runtimeQueueFallbackTimers.set(el, timer); }); @@ -1894,7 +2200,11 @@ onUpdated(() => { // Clean up any stale ghost with same id const existing = runtimeQueueGhosts.get(id); - if (existing) { cancelAnim(existing); existing.remove(); runtimeQueueGhosts.delete(id); } + if (existing) { + cancelAnim(existing); + existing.remove(); + runtimeQueueGhosts.delete(id); + } const ghost = src.cloneNode(true) as HTMLElement; const lr = list.getBoundingClientRect(); @@ -1914,10 +2224,15 @@ onUpdated(() => { // Schedule ghost sink — shares RAF with item animations below const offset = resolveRuntimeQueueOffset(ghost); - scheduleTransition(ghost, ['transform'], + scheduleTransition( + ghost, + ['transform'], { transform: 'translateY(0)' }, { transform: `translateY(${offset}px)` }, - () => { runtimeQueueGhosts.delete(id); ghost.remove(); } + () => { + runtimeQueueGhosts.delete(id); + ghost.remove(); + } ); runtimeQueueItemRefs.delete(id); @@ -1932,7 +2247,9 @@ onUpdated(() => { if (enteringIds.includes(id)) { // New item: rise from below const offset = resolveRuntimeQueueOffset(el); - scheduleTransition(el, ['transform'], + scheduleTransition( + el, + ['transform'], { transform: `translateY(${offset}px)` }, { transform: 'translateY(0)' } ); @@ -1945,7 +2262,9 @@ onUpdated(() => { const next = el.getBoundingClientRect(); const deltaY = prev.top - next.top; if (Math.abs(deltaY) < 0.5) return; - scheduleTransition(el, ['transform'], + scheduleTransition( + el, + ['transform'], { transform: `translateY(${deltaY}px)` }, { transform: 'translateY(0)' } ); @@ -1990,10 +2309,23 @@ const hasRuntimeLayoutExpansion = computed(() => { const hasQueue = runtimeQueuedMessagesForRender.value.length > 0; const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0; const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0; - return hasQueue || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value; + return ( + hasQueue || + floatingStatusVisible.value || + skillSlashOpen.value || + fileAtOpen.value || + hasImages || + hasVideos || + !!props.inputIsMultiline || + !!props.goalModeArmed || + !!props.goalRunning || + goalCompleted.value + ); }); -const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done'); +const goalCompleted = computed( + () => String(props.goalProgress?.status || '').toLowerCase() === 'done' +); const handleSlashMenuTransitionStart = () => { slashMenuTransitioning.value = true; @@ -2047,7 +2379,9 @@ const collectComposerVisualHeight = () => { } // 收起态横幅是脱离流、浮在角上的小圆点,不应计入为聊天区预留的高度, // 否则会把消息区可滚动范围顶高。故排除 .goal-mode-banner--collapsed。 - const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status'); + const nodes = root.querySelectorAll( + '.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status' + ); nodes.forEach((node) => { if (!(node instanceof HTMLElement)) return; // 离开态:floatingStatusVisible 已翻 false 但元素仍在 DOM 中做退场动画。 @@ -2175,9 +2509,58 @@ const onInput = (event: Event) => { nextTick(refreshSkillSlashState); }; -const onFileChange = (event: Event) => { +const insertFileAtMentionByPath = (path: string, name: string) => { + const token = findAtToken(); + const instance = editor.value; + if (!instance || !token) return; + const mentionType = instance.schema.nodes.mention; + if (!mentionType) return; + const mentionNode = mentionType.create({ + id: `file://${path}`, + label: name + }); + const { state, view } = instance; + let tr = state.tr.delete(token.start, token.end); + tr = tr.insert(token.start, mentionNode); + const afterMention = token.start + mentionNode.nodeSize; + tr = tr.insertText(' ', afterMention); + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(afterMention + 1))); + view.dispatch(tr); + view.focus(); + closeFileAtMenu(); + nextTick(() => { + adjustTextareaSize(); + }); +}; + +const onFileChange = async (event: Event) => { const target = event.target as HTMLInputElement; - emit('file-selected', target?.files || null); + const files = target?.files || null; + + if (fileAtPickerActive.value && files && files.length > 0) { + fileAtPickerActive.value = false; + const file = files[0]; + try { + const response = await fetch( + `/api/project/files/search?q=${encodeURIComponent(file.name)}&limit=10` + ); + const data = await response.json().catch(() => ({})); + const items = Array.isArray(data?.data?.items) ? data.data.items : []; + const match = items.find((item: FileAtItem) => item.name === file.name) || items[0]; + if (match) { + insertFileAtMention(match); + } else { + insertFileAtMentionByPath(file.name, file.name); + } + } catch (error) { + console.warn('[FileAt] 通过文件选择器查找路径失败:', error); + insertFileAtMentionByPath(file.name, file.name); + } + if (target) target.value = ''; + return; + } + + emit('file-selected', files); if (target) { target.value = ''; } @@ -2190,7 +2573,9 @@ const getComposerDraftMeta = () => ({ skill_refs: editorJsonToMessage(editor.value?.getJSON() as JSONContent).skillRefs }); -const restoreComposerDraftMeta = (meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null) => { +const restoreComposerDraftMeta = ( + meta?: { skill_refs?: SkillItem[]; editor_json?: JSONContent } | null +) => { const instance = editor.value; if (!instance) return; if (meta?.editor_json && typeof meta.editor_json === 'object') { @@ -2298,7 +2683,11 @@ watch( () => props.inputMessage, async () => { const instance = editor.value; - if (instance && !syncingEditorFromProps && getEditorPlainText() !== (props.inputMessage || '')) { + if ( + instance && + !syncingEditorFromProps && + getEditorPlainText() !== (props.inputMessage || '') + ) { syncingEditorFromProps = true; instance.commands.setContent(textToTiptapContent(props.inputMessage || '')); syncingEditorFromProps = false; @@ -2510,7 +2899,9 @@ onBeforeUnmount(() => { min-width: 0; padding: 0 9px; cursor: pointer; - transition: background 140ms ease, color 140ms ease; + transition: + background 140ms ease, + color 140ms ease; } .floating-project-status__button:hover, @@ -2554,7 +2945,9 @@ onBeforeUnmount(() => { line-height: 1.2; font-variant-numeric: tabular-nums; cursor: pointer; - transition: background 140ms ease, color 140ms ease; + transition: + background 140ms ease, + color 140ms ease; } .floating-project-status__stats:hover { @@ -2586,7 +2979,9 @@ onBeforeUnmount(() => { font-size: 12px; line-height: 1.2; cursor: pointer; - transition: background 140ms ease, color 140ms ease; + transition: + background 140ms ease, + color 140ms ease; } .floating-project-status__terminal:hover { @@ -2689,7 +3084,9 @@ onBeforeUnmount(() => { background: color-mix(in srgb, var(--theme-surface-soft) 92%, var(--claude-text-tertiary) 8%); border: 1px solid var(--theme-chip-border); user-select: none; - transition: gap 0.2s ease, padding 0.2s ease; + transition: + gap 0.2s ease, + padding 0.2s ease; } /* 收起态:仅保留圆点与外圈,文字消失、右侧收拢,圆点位置(左侧 padding)不变。 关键:切到 absolute 脱离文档流,避免横幅占用一行流高度把队列/skill 菜单顶起来; @@ -2708,7 +3105,9 @@ onBeforeUnmount(() => { white-space: nowrap; max-width: 200px; opacity: 1; - transition: max-width 0.2s ease, opacity 0.18s ease; + transition: + max-width 0.2s ease, + opacity 0.18s ease; } .goal-mode-banner--collapsed .goal-mode-banner__text { max-width: 0; @@ -2759,12 +3158,19 @@ onBeforeUnmount(() => { background: var(--claude-success); } @keyframes goal-banner-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } } .goal-banner-fade-enter-active, .goal-banner-fade-leave-active { - transition: opacity 0.18s ease, transform 0.18s ease; + transition: + opacity 0.18s ease, + transform 0.18s ease; } .goal-banner-fade-enter-from, .goal-banner-fade-leave-to { diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index c77a77b..b66d925 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -662,7 +662,8 @@ opacity: 0.9; } -.user-message .message-text .user-skill-link { +.user-message .message-text .user-skill-link, +.user-message .message-text .user-file-link { color: var(--state-info); font-weight: 600; display: inline-block; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index b7d8021..ae8a83c 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -1,1404 +1,1421 @@ /* 输入区域 */ .input-area { - position: absolute; - left: 0; - right: 0; - bottom: 32px; - background: transparent; - padding: 0 24px; - flex-shrink: 0; - pointer-events: none; - z-index: 30; + position: absolute; + left: 0; + right: 0; + bottom: 32px; + background: transparent; + padding: 0 24px; + flex-shrink: 0; + pointer-events: none; + z-index: 30; } .compact-input-area { - display: flex; - justify-content: center; - padding-bottom: 0; - pointer-events: none; + display: flex; + justify-content: center; + padding-bottom: 0; + pointer-events: none; } .stadium-input-wrapper { - position: relative; - width: var(--chat-rail-width, min(900px, 94%)); - pointer-events: auto; + position: relative; + width: var(--chat-rail-width, min(900px, 94%)); + pointer-events: auto; } .runtime-queue-list { - --runtime-queue-bg: var(--theme-surface-soft); - --runtime-queue-divider: rgba(15, 23, 42, 0.12); - --runtime-queue-border: rgba(15, 23, 42, 0.12); - position: absolute; - left: 50%; - bottom: calc(100% + 1px); - transform: translateX(-50%); - z-index: 1; - display: flex; - flex-direction: column; - width: 90%; - max-width: 90%; - margin: 0; - overflow: visible; - background: transparent; - border: none; - border-radius: 0; - box-shadow: none; - pointer-events: auto; + --runtime-queue-bg: var(--theme-surface-soft); + --runtime-queue-divider: rgba(15, 23, 42, 0.12); + --runtime-queue-border: rgba(15, 23, 42, 0.12); + position: absolute; + left: 50%; + bottom: calc(100% + 1px); + transform: translateX(-50%); + z-index: 1; + display: flex; + flex-direction: column; + width: 90%; + max-width: 90%; + margin: 0; + overflow: visible; + background: transparent; + border: none; + border-radius: 0; + box-shadow: none; + pointer-events: auto; } .runtime-queue-list--empty { - height: 0; - border: none; - box-shadow: none; - background: transparent; - margin: 0; - overflow: visible; - pointer-events: none; + height: 0; + border: none; + box-shadow: none; + background: transparent; + margin: 0; + overflow: visible; + pointer-events: none; } .runtime-queue-item { - position: relative; - z-index: var(--runtime-queue-z, 1); - min-height: 34px; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - gap: 10px; - padding: 6px 10px 6px 12px; - border-left: 1px solid var(--runtime-queue-border); - border-right: 1px solid var(--runtime-queue-border); - border-top: 1px solid var(--runtime-queue-border); - border-bottom: none; - border-radius: 0; - background: var(--runtime-queue-bg); - overflow: hidden; + position: relative; + z-index: var(--runtime-queue-z, 1); + min-height: 34px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 10px; + padding: 6px 10px 6px 12px; + border-left: 1px solid var(--runtime-queue-border); + border-right: 1px solid var(--runtime-queue-border); + border-top: 1px solid var(--runtime-queue-border); + border-bottom: none; + border-radius: 0; + background: var(--runtime-queue-bg); + overflow: hidden; } .runtime-queue-item--top { - border-top-left-radius: 12px; - border-top-right-radius: 12px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; } .runtime-queue-item__text { - flex: 1 1 auto; - min-width: 0; - display: block; - font-size: 13px; - line-height: 1.3; - color: var(--claude-text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + flex: 1 1 auto; + min-width: 0; + display: block; + font-size: 13px; + line-height: 1.3; + color: var(--claude-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .runtime-queue-item__action { - flex-shrink: 0; - border: none; - background: transparent; - color: var(--claude-text-secondary); - font-size: 12px; - line-height: 1.2; - padding: 3px 4px; - border-radius: 6px; - cursor: pointer; - position: relative; - z-index: 1; + flex-shrink: 0; + border: none; + background: transparent; + color: var(--claude-text-secondary); + font-size: 12px; + line-height: 1.2; + padding: 3px 4px; + border-radius: 6px; + cursor: pointer; + position: relative; + z-index: 1; } .runtime-queue-item__action:hover { - background: var(--theme-chip-bg); - color: var(--claude-text); + background: var(--theme-chip-bg); + color: var(--claude-text); } .runtime-queue-item__action--danger:hover { - color: var(--state-danger-strong); - background: color-mix(in srgb, var(--state-danger) 12%, transparent); + color: var(--state-danger-strong); + background: color-mix(in srgb, var(--state-danger) 12%, transparent); } .runtime-queue-item + .runtime-queue-item::before { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 0; - height: 1px; - background: var(--runtime-queue-divider); - pointer-events: none; + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + height: 1px; + background: var(--runtime-queue-divider); + pointer-events: none; } .skill-slash-menu-wrapper { - --skill-slash-row-height: 28px; - --skill-slash-gap: 3px; - --skill-slash-radius: 9px; - --skill-slash-pad-x: 4px; - --skill-slash-motion-duration: 300ms; - --skill-slash-motion-easing: cubic-bezier(0.25, 0.8, 0.25, 1); - --skill-slash-visible-rows: 7; - position: absolute; - left: 50%; - bottom: calc(100% - 1px); - transform: translateX(-50%); - z-index: 1; - width: 90%; - max-width: 90%; - box-sizing: border-box; - max-height: calc( - (var(--skill-slash-row-height) * var(--skill-slash-visible-rows)) + - (var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1)) + - 2px + 2px - 2px - ); - border: 1px solid var(--claude-border); - border-top-left-radius: var(--skill-slash-radius); - border-top-right-radius: var(--skill-slash-radius); - background: var(--surface-soft); - box-shadow: none; - pointer-events: auto; + --skill-slash-row-height: 28px; + --skill-slash-gap: 3px; + --skill-slash-radius: 9px; + --skill-slash-pad-x: 4px; + --skill-slash-motion-duration: 300ms; + --skill-slash-motion-easing: cubic-bezier(0.25, 0.8, 0.25, 1); + --skill-slash-visible-rows: 7; + position: absolute; + left: 50%; + bottom: calc(100% - 1px); + transform: translateX(-50%); + z-index: 1; + width: 90%; + max-width: 90%; + box-sizing: border-box; + max-height: calc( + (var(--skill-slash-row-height) * var(--skill-slash-visible-rows)) + + (var(--skill-slash-gap) * (var(--skill-slash-visible-rows) + 1)) + 2px + 2px - 2px + ); + border: 1px solid var(--claude-border); + border-top-left-radius: var(--skill-slash-radius); + border-top-right-radius: var(--skill-slash-radius); + background: var(--surface-soft); + box-shadow: none; + pointer-events: auto; } .skill-slash-menu-motion-enter-active, .skill-slash-menu-motion-leave-active { - transition: - transform var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)), - clip-path var(--skill-slash-motion-duration, 300ms) var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)); - transform-origin: bottom center; - will-change: transform, clip-path; + transition: + transform var(--skill-slash-motion-duration, 300ms) + var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)), + clip-path var(--skill-slash-motion-duration, 300ms) + var(--skill-slash-motion-easing, cubic-bezier(0.25, 0.8, 0.25, 1)); + transform-origin: bottom center; + will-change: transform, clip-path; } .skill-slash-menu-motion-enter-active, .skill-slash-menu-motion-leave-active { - z-index: 1; + z-index: 1; } .skill-slash-menu-motion-enter-from, .skill-slash-menu-motion-leave-to { - transform: translateX(-50%) translateY(100%); - clip-path: inset(0 0 100% 0 round 12px 12px 0 0); + transform: translateX(-50%) translateY(100%); + clip-path: inset(0 0 100% 0 round 12px 12px 0 0); } .skill-slash-menu-motion-enter-to, .skill-slash-menu-motion-leave-from { - transform: translateX(-50%) translateY(0); - clip-path: inset(0 0 0 0 round 12px 12px 0 0); + transform: translateX(-50%) translateY(0); + clip-path: inset(0 0 0 0 round 12px 12px 0 0); } .skill-slash-menu { - height: 100%; - max-height: inherit; - overflow-y: auto; - scrollbar-width: none; - padding: 4px var(--skill-slash-pad-x) 6px; - display: flex; - flex-direction: column; - gap: var(--skill-slash-gap); + height: 100%; + max-height: inherit; + overflow-y: auto; + scrollbar-width: none; + padding: 4px var(--skill-slash-pad-x) 6px; + display: flex; + flex-direction: column; + gap: var(--skill-slash-gap); } .skill-slash-menu::-webkit-scrollbar { - width: 0; - height: 0; + width: 0; + height: 0; } .skill-slash-menu__highlight { - position: absolute; - left: var(--skill-slash-pad-x); - right: var(--skill-slash-pad-x); - top: var(--skill-slash-gap); - height: var(--skill-slash-row-height); - border-radius: 7px; - background: var(--hover-bg); - box-shadow: 0 1px 2px var(--shadow-color); - pointer-events: none; - z-index: 1; + position: absolute; + left: var(--skill-slash-pad-x); + right: var(--skill-slash-pad-x); + top: var(--skill-slash-gap); + height: var(--skill-slash-row-height); + border-radius: 7px; + background: var(--hover-bg); + box-shadow: 0 1px 2px var(--shadow-color); + pointer-events: none; + z-index: 1; } .skill-slash-item { - width: 100%; - height: var(--skill-slash-row-height); - min-height: var(--skill-slash-row-height); - flex: 0 0 var(--skill-slash-row-height); - display: flex; - align-items: center; - gap: 7px; - border: none; - border-radius: 7px; - padding: 3px 9px; - background: transparent; - color: var(--claude-text); - text-align: left; - cursor: pointer; + width: 100%; + height: var(--skill-slash-row-height); + min-height: var(--skill-slash-row-height); + flex: 0 0 var(--skill-slash-row-height); + display: flex; + align-items: center; + gap: 7px; + border: none; + border-radius: 7px; + padding: 3px 9px; + background: transparent; + color: var(--claude-text); + text-align: left; + cursor: pointer; } .skill-slash-item:first-child { - border-top-left-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap)); - border-top-right-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap)); + border-top-left-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap)); + border-top-right-radius: calc(var(--skill-slash-radius) - var(--skill-slash-gap)); } .skill-slash-item:hover { - background: var(--hover-bg); - box-shadow: 0 1px 2px var(--shadow-color); + background: var(--hover-bg); + box-shadow: 0 1px 2px var(--shadow-color); } .skill-slash-item--active { - background: transparent; - box-shadow: none; + background: transparent; + box-shadow: none; } .skill-slash-item--active:hover { - background: transparent; - box-shadow: none; + background: transparent; + box-shadow: none; } .skill-slash-item--disabled, .skill-slash-item:disabled { - opacity: 0.45; - cursor: not-allowed; + opacity: 0.45; + cursor: not-allowed; } .skill-slash-item--disabled:hover, .skill-slash-item:disabled:hover { - background: transparent; - box-shadow: none; + background: transparent; + box-shadow: none; } .skill-slash-item__name { - flex: 0 0 auto; - max-width: 160px; - color: var(--accent); - font-size: 12px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + flex: 0 0 auto; + max-width: 160px; + color: var(--accent); + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .skill-slash-item__description { - flex: 1 1 auto; - min-width: 0; - color: var(--claude-text-secondary); - font-size: 12px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + flex: 1 1 auto; + min-width: 0; + color: var(--claude-text-secondary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .skill-slash-empty { - height: var(--skill-slash-row-height); - min-height: var(--skill-slash-row-height); - flex: 0 0 var(--skill-slash-row-height); - display: flex; - align-items: center; - padding: 3px 9px; - color: var(--claude-text-secondary); - font-size: 11px; + height: var(--skill-slash-row-height); + min-height: var(--skill-slash-row-height); + flex: 0 0 var(--skill-slash-row-height); + display: flex; + align-items: center; + padding: 3px 9px; + color: var(--claude-text-secondary); + font-size: 11px; } body[data-theme='dark'] { - .runtime-queue-list { - --runtime-queue-bg: var(--badge-bg); - --runtime-queue-divider: rgba(255, 255, 255, 0.12); - --runtime-queue-border: rgba(255, 255, 255, 0.12); - background: transparent; - } + .runtime-queue-list { + --runtime-queue-bg: var(--badge-bg); + --runtime-queue-divider: rgba(255, 255, 255, 0.12); + --runtime-queue-border: rgba(255, 255, 255, 0.12); + background: transparent; + } - .runtime-queue-item { - background: var(--runtime-queue-bg); - } + .runtime-queue-item { + background: var(--runtime-queue-bg); + } - .runtime-queue-list.runtime-queue-list--empty { - background: transparent; - border: none; - box-shadow: none; - } + .runtime-queue-list.runtime-queue-list--empty { + background: transparent; + border: none; + box-shadow: none; + } - .skill-slash-menu-wrapper { - background: var(--badge-bg); - border-color: var(--claude-border); - } + .skill-slash-menu-wrapper { + background: var(--badge-bg); + border-color: var(--claude-border); + } - .skill-slash-item__name { - color: var(--state-info); - } + .skill-slash-item__name { + color: var(--state-info); + } - .skill-slash-item:hover { - background: var(--hover-bg); - box-shadow: 0 1px 2px var(--shadow-color); - } + .skill-slash-item:hover { + background: var(--hover-bg); + box-shadow: 0 1px 2px var(--shadow-color); + } - .skill-slash-item--active { - background: transparent; - box-shadow: none; - } + .skill-slash-item--active { + background: transparent; + box-shadow: none; + } - .skill-slash-item--active:hover { - background: transparent; - box-shadow: none; - } + .skill-slash-item--active:hover { + background: transparent; + box-shadow: none; + } - .stadium-input-editor .skill-md-link, - .stadium-input-highlight .skill-md-link { - color: var(--state-info); - } + .stadium-input-editor .skill-md-link, + .stadium-input-highlight .skill-md-link { + color: var(--state-info); + } } body[data-theme='light'] { - .skill-slash-item__name { - color: var(--state-info); - } + .skill-slash-item__name { + color: var(--state-info); + } - .stadium-input-editor .skill-md-link, - .stadium-input-highlight .skill-md-link { - color: var(--state-info); - } + .stadium-input-editor .skill-md-link, + .stadium-input-highlight .skill-md-link { + color: var(--state-info); + } } .permission-switcher { - --composer-side-control-offset: 20px; - position: absolute; - left: var(--composer-side-control-offset); - bottom: -28px; - z-index: 35; - display: inline-flex; - align-items: center; - gap: 6px; + --composer-side-control-offset: 20px; + position: absolute; + left: var(--composer-side-control-offset); + bottom: -28px; + z-index: 35; + display: inline-flex; + align-items: center; + gap: 6px; } .permission-switcher__block { - position: relative; + position: relative; } .context-usage-switcher { - --composer-side-control-offset: 20px; - --context-ring-visual-inset: 5px; - position: absolute; - right: calc(var(--composer-side-control-offset) - var(--context-ring-visual-inset)); - bottom: -28px; - z-index: 35; + --composer-side-control-offset: 20px; + --context-ring-visual-inset: 5px; + position: absolute; + right: calc(var(--composer-side-control-offset) - var(--context-ring-visual-inset)); + bottom: -28px; + z-index: 35; } .context-usage-switcher__btn { - border: none; - background: transparent; - padding: 0; - width: 28px; - height: 28px; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: none; + border: none; + background: transparent; + padding: 0; + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: none; } .context-usage-switcher__btn:hover:not(:disabled) { - background: var(--theme-chip-bg); + background: var(--theme-chip-bg); } .context-usage-switcher__btn:disabled { - opacity: 0.45; - cursor: not-allowed; + opacity: 0.45; + cursor: not-allowed; } .context-usage-ring { - --context-progress: 0%; - --context-ring-color: var(--claude-accent); - width: 18px; - height: 18px; - border-radius: 50%; - background: - conic-gradient(var(--context-ring-color) var(--context-progress), var(--progress-track) 0); - display: inline-flex; - align-items: center; - justify-content: center; + --context-progress: 0%; + --context-ring-color: var(--claude-accent); + width: 18px; + height: 18px; + border-radius: 50%; + background: conic-gradient( + var(--context-ring-color) var(--context-progress), + var(--progress-track) 0 + ); + display: inline-flex; + align-items: center; + justify-content: center; } .context-usage-ring__inner { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--theme-surface-soft); - border: 1px solid var(--theme-control-border); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--theme-surface-soft); + border: 1px solid var(--theme-control-border); } .context-usage-switcher__tooltip { - position: absolute; - right: 0; - bottom: calc(100% + 8px); - min-width: 94px; - padding: 6px 8px; - border-radius: 10px; - border: 1px solid var(--theme-control-border); - background: var(--theme-surface-soft); - box-shadow: none; - color: var(--claude-text); - font-size: 12px; - line-height: 1.35; - opacity: 0; - transform: translateY(4px); - pointer-events: none; - transition: opacity 0.16s ease, transform 0.16s ease; + position: absolute; + right: 0; + bottom: calc(100% + 8px); + min-width: 94px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--theme-control-border); + background: var(--theme-surface-soft); + box-shadow: none; + color: var(--claude-text); + font-size: 12px; + line-height: 1.35; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.16s ease; } .context-usage-switcher:hover .context-usage-switcher__tooltip { - opacity: 1; - transform: translateY(0); + opacity: 1; + transform: translateY(0); } .permission-switcher__btn { - border: none; - background: transparent; - color: var(--claude-text); - border-radius: 999px; - padding: 4px 12px; - font-size: 14px; - line-height: 1.2; - cursor: pointer; - box-shadow: none; - text-decoration: none; - display: inline-flex; - align-items: center; - gap: 6px; + border: none; + background: transparent; + color: var(--claude-text); + border-radius: 999px; + padding: 4px 12px; + font-size: 14px; + line-height: 1.2; + cursor: pointer; + box-shadow: none; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; } .permission-switcher__btn:hover:not(:disabled) { - color: var(--claude-text); - text-decoration: none; - background: var(--theme-chip-bg); - box-shadow: none; + color: var(--claude-text); + text-decoration: none; + background: var(--theme-chip-bg); + box-shadow: none; } .permission-switcher__btn:disabled { - opacity: 0.45; - cursor: not-allowed; + opacity: 0.45; + cursor: not-allowed; } .permission-switcher__menu { - position: absolute; - left: 0; - bottom: calc(100% + 8px); - width: 250px; - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px; - border: 1px solid var(--theme-control-border); - border-radius: 14px; - background: var(--theme-surface-soft); - box-shadow: none; + position: absolute; + left: 0; + bottom: calc(100% + 8px); + width: 250px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 1px solid var(--theme-control-border); + border-radius: 14px; + background: var(--theme-surface-soft); + box-shadow: none; } .permission-switcher__menu--split { - width: 560px; - max-width: min(560px, calc(100vw - 40px)); - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; + width: 560px; + max-width: min(560px, calc(100vw - 40px)); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; - > :first-child.permission-switcher__group { - grid-row: span 2; - } + > :first-child.permission-switcher__group { + grid-row: span 2; + } } .permission-switcher__group { - display: flex; - flex-direction: column; - gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; - &--disabled { - opacity: 0.35; - pointer-events: none; - } + &--disabled { + opacity: 0.35; + pointer-events: none; + } } .permission-switcher__group-title { - font-size: 12px; - color: var(--claude-text-secondary); - font-weight: 600; - padding: 2px 4px; + font-size: 12px; + color: var(--claude-text-secondary); + font-weight: 600; + padding: 2px 4px; } .permission-switcher__caret { - font-size: 14px; - color: var(--claude-text-secondary); - transform: rotate(90deg); - transition: transform 0.18s ease; + font-size: 14px; + color: var(--claude-text-secondary); + transform: rotate(90deg); + transition: transform 0.18s ease; } .permission-switcher__caret.open { - transform: rotate(270deg); + transform: rotate(270deg); } .permission-switcher__item { - border: none; - background: transparent; - text-align: left; - border-radius: 10px; - padding: 8px 10px; - cursor: pointer; - color: var(--claude-text); - display: flex; - flex-direction: column; - gap: 2px; + border: none; + background: transparent; + text-align: left; + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + color: var(--claude-text); + display: flex; + flex-direction: column; + gap: 2px; } .permission-switcher__item:hover { - background: var(--theme-chip-bg); + background: var(--theme-chip-bg); } .permission-switcher__item.active { - background: var(--theme-tab-active); + background: var(--theme-tab-active); } .permission-switcher__item-label { - font-size: 13px; - font-weight: 600; + font-size: 13px; + font-weight: 600; } .permission-switcher__item-desc { - font-size: 11px; - color: var(--claude-text-secondary); - line-height: 1.35; + font-size: 11px; + color: var(--claude-text-secondary); + line-height: 1.35; } .permission-switcher__item-label--warn { - color: var(--state-warning); + color: var(--state-warning); } @media (max-width: 768px) { - .permission-switcher { - left: var(--composer-side-control-offset); - bottom: -28px; - } + .permission-switcher { + left: var(--composer-side-control-offset); + bottom: -28px; + } - .context-usage-switcher { - right: calc(var(--composer-side-control-offset) - var(--context-ring-visual-inset)); - bottom: -28px; - } + .context-usage-switcher { + right: calc(var(--composer-side-control-offset) - var(--context-ring-visual-inset)); + bottom: -28px; + } - /* 权限菜单在手机端自适应位置,避免跑出屏幕 */ - .permission-switcher__menu { - left: auto; - right: 0; - width: min(250px, calc(100vw - 40px)); - max-width: 250px; - } - .permission-switcher__menu--split { - width: min(560px, calc(100vw - 40px)); - max-width: min(560px, calc(100vw - 40px)); - grid-template-columns: 1fr; - } + /* 权限菜单在手机端自适应位置,避免跑出屏幕 */ + .permission-switcher__menu { + left: auto; + right: 0; + width: min(250px, calc(100vw - 40px)); + max-width: 250px; + } + .permission-switcher__menu--split { + width: min(560px, calc(100vw - 40px)); + max-width: min(560px, calc(100vw - 40px)); + grid-template-columns: 1fr; + } } .stadium-shell { - --stadium-radius: 18px; - position: relative; - z-index: 2; - width: 100%; - padding: 10px; - border-radius: var(--stadium-radius); - border: 1px solid var(--border-default); - background: var(--surface-raised); - box-shadow: none; - display: flex; - gap: 12px; - transition: - box-shadow 0.45s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.45s cubic-bezier(0.4, 0, 0.2, 1); + --stadium-radius: 18px; + position: relative; + z-index: 2; + width: 100%; + padding: 10px; + border-radius: var(--stadium-radius); + border: 1px solid var(--border-default); + background: var(--surface-raised); + box-shadow: none; + display: flex; + gap: 12px; + transition: + box-shadow 0.45s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.45s cubic-bezier(0.4, 0, 0.2, 1); } .stadium-shell.is-multiline { - border-color: var(--border-strong); - box-shadow: none; + border-color: var(--border-strong); + box-shadow: none; } .stadium-shell.is-focused, .stadium-shell.has-text { - border-color: var(--border-default); - box-shadow: none; + border-color: var(--border-default); + box-shadow: none; } .stadium-shell.is-multiline.is-focused, .stadium-shell.is-multiline.has-text { - border-color: var(--border-strong); - box-shadow: none; + border-color: var(--border-strong); + box-shadow: none; } .input-stack { - display: flex; - flex-direction: column; - flex: 1 1 auto; - gap: 6px; + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: 6px; } .input-row { - display: flex; - align-items: flex-start; - gap: 12px; - width: 100%; + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; } .input-actions { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; } .stadium-input-rich { - position: relative; - flex: 1 1 auto; - min-width: 0; - display: grid; - padding: 2px 0 0 6px; + position: relative; + flex: 1 1 auto; + min-width: 0; + display: grid; + padding: 2px 0 0 6px; } .stadium-input-rich .stadium-input { - grid-area: 1 / 1; + grid-area: 1 / 1; } -.stadium-input-editor .skill-md-link { - color: var(--accent); - font-weight: inherit; - border-radius: 4px; - padding: 0 1px; - white-space: nowrap; +.stadium-input-editor .skill-md-link, +.stadium-input-editor .file-md-link { + color: var(--accent); + font-weight: inherit; + border-radius: 4px; + padding: 0 1px; + white-space: nowrap; + cursor: pointer; +} + +.stadium-input-editor .file-md-link { + color: var(--state-info); } .stadium-input-editor p.is-editor-empty:first-child::before { - content: attr(data-placeholder); - color: var(--claude-text-tertiary); - float: left; - height: 0; - pointer-events: none; + content: attr(data-placeholder); + color: var(--claude-text-tertiary); + float: left; + height: 0; + pointer-events: none; } .image-inline-row { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 0 4px 0; - font-size: 12px; - color: var(--text-secondary); - line-height: 1.4; + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 4px 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; } .image-name { - white-space: nowrap; - display: inline-flex; - align-items: center; - gap: 6px; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 6px; } .image-remove-btn { - border: none; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - padding: 0 4px; - font-size: 12px; - line-height: 1; - transition: color 0.15s ease, transform 0.15s ease; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0 4px; + font-size: 12px; + line-height: 1; + transition: + color 0.15s ease, + transform 0.15s ease; } .image-remove-btn:hover { - color: var(--state-danger); - transform: scale(1.05); + color: var(--state-danger); + transform: scale(1.05); } .stadium-input { - flex: 1 1 auto; - width: 100%; - border: none; - resize: none; - background: transparent; - font-size: 14px; - line-height: 1.4; - font-family: inherit; - color: var(--claude-text); - white-space: pre-wrap; - word-break: break-all; - overflow-wrap: normal; - padding: 0; - min-height: 20px; - outline: none; - overflow-y: auto; - scrollbar-width: none; - transition: none; - position: relative; - z-index: 1; + flex: 1 1 auto; + width: 100%; + border: none; + resize: none; + background: transparent; + font-size: 14px; + line-height: 1.4; + font-family: inherit; + color: var(--claude-text); + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: normal; + padding: 0; + min-height: 20px; + outline: none; + overflow-y: auto; + scrollbar-width: none; + transition: none; + position: relative; + z-index: 1; } .stadium-input-tiptap { - display: block; + display: block; } .stadium-input-tiptap.is-disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .stadium-input-editor { - cursor: text; - min-height: 20px; - outline: none; - overflow-y: auto; - scrollbar-width: none; - white-space: pre-wrap; - word-break: break-all; - overflow-wrap: normal; + cursor: text; + min-height: 20px; + outline: none; + overflow-y: auto; + scrollbar-width: none; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: normal; } .stadium-input-editor p { - margin: 0; + margin: 0; } .stadium-input-editor::-webkit-scrollbar { - width: 0; - height: 0; + width: 0; + height: 0; } .stadium-input:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .stadium-input::-webkit-scrollbar { - width: 0; - height: 0; + width: 0; + height: 0; } .stadium-btn { - flex: 0 0 28px; - width: 28px; - height: 28px; - border: none; - border-radius: 7px; - background: transparent; - color: var(--claude-text); - font-size: 16px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background 140ms ease, color 140ms ease; + flex: 0 0 28px; + width: 28px; + height: 28px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--claude-text); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: + background 140ms ease, + color 140ms ease; } .stadium-btn:disabled { - opacity: 0.4; - cursor: not-allowed; + opacity: 0.4; + cursor: not-allowed; } .stadium-btn:hover:not(:disabled) { - background: var(--hover-bg); + background: var(--hover-bg); } .add-btn { - font-size: 20px; + font-size: 20px; } .stadium-btn.send-btn { - background: var(--claude-accent); - color: var(--on-accent); - box-shadow: none; + background: var(--claude-accent); + color: var(--on-accent); + box-shadow: none; } .stadium-btn.send-btn:hover:not(:disabled) { - background: var(--claude-button-hover); + background: var(--claude-button-hover); } .stadium-btn.send-btn:disabled { - opacity: 0.4; - box-shadow: none; + opacity: 0.4; + box-shadow: none; } .stadium-btn.send-btn span { - position: relative; - top: 0px; + position: relative; + top: 0px; } .stadium-btn.send-btn .send-icon { - width: 0; - height: 0; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: 9px solid var(--on-accent); - margin-left: 4px; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 9px solid var(--on-accent); + margin-left: 4px; } .stadium-btn.send-btn .stop-icon { - width: 11px; - height: 11px; - border-radius: 2px; - background-color: var(--on-accent); - display: block; + width: 11px; + height: 11px; + border-radius: 2px; + background-color: var(--on-accent); + display: block; } .stadium-btn.send-btn:disabled .send-icon { - border-left-color: color-mix(in srgb, var(--on-accent) 40%, transparent); + border-left-color: color-mix(in srgb, var(--on-accent) 40%, transparent); } .stadium-btn.send-btn:disabled .stop-icon { - background-color: color-mix(in srgb, var(--on-accent) 40%, transparent); + background-color: color-mix(in srgb, var(--on-accent) 40%, transparent); } .input-actions-right { - display: flex; - align-items: center; - gap: 6px; + display: flex; + align-items: center; + gap: 6px; } /* 语音输入按钮 */ .voice-btn { - color: var(--text-primary); + color: var(--text-primary); } :root[data-theme='classic'] .voice-btn, :root:not([data-theme]) .voice-btn { - color: var(--accent); + color: var(--accent); } .voice-btn:hover:not(:disabled) { - background: var(--hover-bg); + background: var(--hover-bg); } .voice-btn:disabled { - opacity: 0.4; - cursor: not-allowed; + opacity: 0.4; + cursor: not-allowed; } .voice-btn .mic-icon { - display: block; - flex-shrink: 0; + display: block; + flex-shrink: 0; } /* 声纹动画 */ .voice-btn--recording { - background: var(--hover-bg) !important; + background: var(--hover-bg) !important; } .voice-btn--disabled { - opacity: 0.5; - cursor: not-allowed; - color: var(--text-secondary); + opacity: 0.5; + cursor: not-allowed; + color: var(--text-secondary); } .voice-wave { - display: flex; - align-items: center; - justify-content: center; - gap: 2.5px; - height: 18px; + display: flex; + align-items: center; + justify-content: center; + gap: 2.5px; + height: 18px; } .voice-wave-bar { - display: inline-block; - width: 2.5px; - height: 16px; - border-radius: 2px; - background: currentColor; - transform-origin: center; - animation: voice-wave-pulse 0.9s ease-in-out infinite; + display: inline-block; + width: 2.5px; + height: 16px; + border-radius: 2px; + background: currentColor; + transform-origin: center; + animation: voice-wave-pulse 0.9s ease-in-out infinite; } .voice-wave-bar:nth-child(1) { - animation-delay: 0s; - animation-duration: 0.75s; + animation-delay: 0s; + animation-duration: 0.75s; } .voice-wave-bar:nth-child(2) { - animation-delay: 0.15s; - animation-duration: 0.9s; + animation-delay: 0.15s; + animation-duration: 0.9s; } .voice-wave-bar:nth-child(3) { - animation-delay: 0.3s; - animation-duration: 1.05s; + animation-delay: 0.3s; + animation-duration: 1.05s; } .voice-wave-bar:nth-child(4) { - animation-delay: 0.1s; - animation-duration: 0.85s; + animation-delay: 0.1s; + animation-duration: 0.85s; } @keyframes voice-wave-pulse { - 0%, 100% { - transform: scaleY(0.25); - opacity: 0.6; - } - 50% { - transform: scaleY(1); - opacity: 1; - } + 0%, + 100% { + transform: scaleY(0.25); + opacity: 0.6; + } + 50% { + transform: scaleY(1); + opacity: 1; + } } .file-input-hidden { - position: absolute; - opacity: 0; - width: 0; - height: 0; - pointer-events: none; + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; } .quick-menu { - position: absolute; - left: 0; - bottom: calc(100% + 14px); - display: flex; - flex-direction: column; - gap: 6px; - width: 230px; - padding: 12px; - background: var(--surface-soft); - border: 1px solid var(--claude-border); - border-radius: 18px; - box-shadow: none; - z-index: 30; - pointer-events: auto; + position: absolute; + left: 0; + bottom: calc(100% + 14px); + display: flex; + flex-direction: column; + gap: 6px; + width: 230px; + padding: 12px; + background: var(--surface-soft); + border: 1px solid var(--claude-border); + border-radius: 18px; + box-shadow: none; + z-index: 30; + pointer-events: auto; } .menu-entry { - border: none; - background: transparent; - -webkit-appearance: none; - appearance: none; - -webkit-tap-highlight-color: transparent; - padding: 10px 12px; - border-radius: 12px; - font-size: 14px; - text-align: left; - color: var(--claude-text); - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - transition: background 0.15s ease; - min-height: 44px; + border: none; + background: transparent; + -webkit-appearance: none; + appearance: none; + -webkit-tap-highlight-color: transparent; + padding: 10px 12px; + border-radius: 12px; + font-size: 14px; + text-align: left; + color: var(--claude-text); + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + transition: background 0.15s ease; + min-height: 44px; } .menu-entry:focus, .menu-entry:focus-visible { - outline: none; + outline: none; } .menu-entry:hover:not(:disabled) { - background: var(--hover-bg); + background: var(--hover-bg); } .menu-entry.active { - background: var(--chip-bg); - color: var(--claude-text); - font-weight: 600; + background: var(--chip-bg); + color: var(--claude-text); + font-weight: 600; } .menu-entry:disabled { - opacity: 0.45; - cursor: not-allowed; + opacity: 0.45; + cursor: not-allowed; } .menu-entry.has-submenu .entry-arrow { - margin-left: 10px; - color: var(--claude-text-secondary); + margin-left: 10px; + color: var(--claude-text-secondary); } .quick-submenu { - position: absolute; - top: 0; - left: calc(100% + 12px); - width: 230px; - min-width: 0; - padding: 12px; - border-radius: 18px; - border: 1px solid var(--claude-border); - background: var(--surface-soft); - box-shadow: none; - z-index: 31; + position: absolute; + top: 0; + left: calc(100% + 12px); + width: 230px; + min-width: 0; + padding: 12px; + border-radius: 18px; + border: 1px solid var(--claude-border); + background: var(--surface-soft); + box-shadow: none; + z-index: 31; } .submenu-status, .submenu-empty { - font-size: 13px; - color: var(--claude-text-secondary); + font-size: 13px; + color: var(--claude-text-secondary); } .submenu-list { - display: flex; - flex-direction: column; - gap: 6px; + display: flex; + flex-direction: column; + gap: 6px; } /* Blank conversation hero */ .chat-container { - position: relative; + position: relative; } .blank-hero-overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - pointer-events: none; - z-index: 1; - gap: 10px; - padding-bottom: 160px; - color: var(--text-primary); + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + pointer-events: none; + z-index: 1; + gap: 10px; + padding-bottom: 160px; + color: var(--text-primary); } .blank-hero-text { - font-size: 32px; - color: var(--text-primary); - font-weight: 600; - margin: 0; - max-width: min(88vw, 680px); - text-align: center; - line-height: 1.3; - text-wrap: balance; + font-size: 32px; + color: var(--text-primary); + font-weight: 600; + margin: 0; + max-width: min(88vw, 680px); + text-align: center; + line-height: 1.3; + text-wrap: balance; } .composer-container { - position: relative; - height: var(--composer-base-height, calc(90px + var(--app-bottom-inset))); - flex: 0 0 var(--composer-base-height, calc(90px + var(--app-bottom-inset))); - transition: transform 0.3s ease; - z-index: 2; + position: relative; + height: var(--composer-base-height, calc(90px + var(--app-bottom-inset))); + flex: 0 0 var(--composer-base-height, calc(90px + var(--app-bottom-inset))); + transition: transform 0.3s ease; + z-index: 2; } .composer-container.blank-hero-mode { - // 新对话居中态:当输入栏上方出现 git 状态栏或输入变多行时,整体可视高度会向上增长, - // 顶到欢迎文字。用已计算好的 --composer-growth-height 把输入栏整体下移同样的距离, - // 让栈顶回到「裸单行输入」时的位置,从而保证不遮挡欢迎文字。 - transform: translateY(calc(-38vh + var(--composer-growth-height, 0px))); + // 新对话居中态:当输入栏上方出现 git 状态栏或输入变多行时,整体可视高度会向上增长, + // 顶到欢迎文字。用已计算好的 --composer-growth-height 把输入栏整体下移同样的距离, + // 让栈顶回到「裸单行输入」时的位置,从而保证不遮挡欢迎文字。 + transform: translateY(calc(-38vh + var(--composer-growth-height, 0px))); } @media (max-width: 768px) { - .input-area { - padding: 0 12px; - } + .input-area { + padding: 0 12px; + } - .blank-hero-text { - font-size: 28px; - } - .blank-hero-overlay .icon-lg { - width: 44px; - height: 44px; - } - .composer-container.blank-hero-mode { - transform: translateY(calc(-32vh + var(--composer-growth-height, 0px))); - } - .blank-hero-overlay { - padding-bottom: 136px; - } + .blank-hero-text { + font-size: 28px; + } + .blank-hero-overlay .icon-lg { + width: 44px; + height: 44px; + } + .composer-container.blank-hero-mode { + transform: translateY(calc(-32vh + var(--composer-growth-height, 0px))); + } + .blank-hero-overlay { + padding-bottom: 136px; + } } .quick-submenu.tool-submenu { - top: auto; - bottom: 0; + top: auto; + bottom: 0; } .quick-submenu.settings-submenu { - top: auto; - bottom: 0; + top: auto; + bottom: 0; } .menu-entry.submenu-entry { - width: 100%; - justify-content: space-between; + width: 100%; + justify-content: space-between; } .menu-entry.submenu-entry .entry-arrow { - color: var(--claude-text-secondary); + color: var(--claude-text-secondary); } .tool-submenu-list { - // 显示约 5 条工具项,其余通过滚动查看 - max-height: calc(44px * 5 + 6px * 4); - overflow-y: auto; - overscroll-behavior: contain; - padding-right: 2px; - scrollbar-width: none; - -ms-overflow-style: none; + // 显示约 5 条工具项,其余通过滚动查看 + max-height: calc(44px * 5 + 6px * 4); + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 2px; + scrollbar-width: none; + -ms-overflow-style: none; } .tool-submenu-list::-webkit-scrollbar { - width: 0; - height: 0; - display: none; + width: 0; + height: 0; + display: none; } .menu-entry.disabled { - opacity: 0.5; + opacity: 0.5; } - - .quick-menu-enter-active, .quick-menu-leave-active { - transition: opacity 0.2s ease, transform 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .quick-menu-enter-from, .quick-menu-leave-to { - opacity: 0; - transform: translateY(8px); + opacity: 0; + transform: translateY(8px); } .submenu-slide-enter-active, .submenu-slide-leave-active { - transition: opacity 0.2s ease, transform 0.2s ease; + transition: + opacity 0.2s ease, + transform 0.2s ease; } .submenu-slide-enter-from, .submenu-slide-leave-to { - opacity: 0; - transform: translateX(10px); + opacity: 0; + transform: translateX(10px); } /* 适应折叠屏/矮屏幕,保证输入区完整可见 */ @media (max-height: 900px) { - .input-area { - bottom: 12px; - } + .input-area { + bottom: 12px; + } } .settings-menu { - position: absolute; - right: 0; - bottom: calc(100% + 12px); - background: var(--surface-soft); - border: 1px solid var(--claude-border); - border-radius: 12px; - box-shadow: none; - padding: 12px; - display: flex; - flex-direction: column; - gap: 8px; - min-width: 150px; - z-index: 40; + position: absolute; + right: 0; + bottom: calc(100% + 12px); + background: var(--surface-soft); + border: 1px solid var(--claude-border); + border-radius: 12px; + box-shadow: none; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 150px; + z-index: 40; } .settings-menu::before { - content: ''; - position: absolute; - bottom: -10px; - right: 20px; - border-width: 10px 10px 0 10px; - border-style: solid; - border-color: var(--surface-soft) transparent transparent transparent; - filter: drop-shadow(0 3px 4px rgba(61, 57, 41, 0.12)); + content: ''; + position: absolute; + bottom: -10px; + right: 20px; + border-width: 10px 10px 0 10px; + border-style: solid; + border-color: var(--surface-soft) transparent transparent transparent; + filter: drop-shadow(0 3px 4px rgba(61, 57, 41, 0.12)); } .settings-menu.tool-menu { - right: auto; - left: 0; - min-width: 520px; - max-width: 580px; - padding: 16px 20px; + right: auto; + left: 0; + min-width: 520px; + max-width: 580px; + padding: 16px 20px; } .settings-menu.tool-menu::before { - left: 32px; - right: auto; + left: 32px; + right: auto; } .tool-menu .tool-menu-status, .tool-menu .tool-menu-empty { - font-size: 13px; - color: var(--text-secondary); - text-align: left; + font-size: 13px; + color: var(--text-secondary); + text-align: left; } .tool-menu .tool-menu-list { - display: grid; - grid-template-columns: repeat(4, minmax(110px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + gap: 12px; } .tool-menu .tool-category-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - padding: 14px 10px; - border: 1px solid var(--border-default); - border-radius: 10px; - background: var(--surface-soft); - font-size: 13px; - aspect-ratio: 1 / 1; - min-height: 0; - justify-content: space-between; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 14px 10px; + border: 1px solid var(--border-default); + border-radius: 10px; + background: var(--surface-soft); + font-size: 13px; + aspect-ratio: 1 / 1; + min-height: 0; + justify-content: space-between; } .tool-menu .tool-category-item.disabled { - opacity: 0.55; + opacity: 0.55; } .tool-menu .tool-category-label { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--claude-text); - display: inline-flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - text-align: center; - white-space: nowrap; - line-height: 1.4; + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--claude-text); + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + text-align: center; + white-space: nowrap; + line-height: 1.4; } .tool-category-icon { - font-size: 20px; + font-size: 20px; } .tool-menu .tool-category-toggle { - width: 100% !important; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 6px 12px; - text-align: center; - white-space: nowrap; - margin-top: auto; + width: 100% !important; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; + text-align: center; + white-space: nowrap; + margin-top: auto; } .menu-btn { - width: 100%; - padding: 8px 14px; - border: 1px solid var(--border-default); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - text-align: left; - background: var(--surface-soft); - color: var(--claude-text); - cursor: pointer; - transition: all 0.2s ease; + width: 100%; + padding: 8px 14px; + border: 1px solid var(--border-default); + border-radius: 8px; + font-size: 13px; + font-weight: 500; + text-align: left; + background: var(--surface-soft); + color: var(--claude-text); + cursor: pointer; + transition: all 0.2s ease; } .menu-btn:not(:disabled):hover { - background: var(--surface-soft); - transform: translateY(-1px); + background: var(--surface-soft); + transform: translateY(-1px); } .menu-btn:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .menu-btn.compress-entry { - background: var(--surface-soft); - color: var(--claude-success); + background: var(--surface-soft); + color: var(--claude-success); } .menu-btn.compress-entry:not(:disabled):hover { - background: var(--surface-soft); + background: var(--surface-soft); } .menu-btn.clear-entry { - background: var(--surface-soft); - color: var(--state-danger); + background: var(--surface-soft); + color: var(--state-danger); } .menu-btn.clear-entry:not(:disabled):hover { - background: var(--surface-soft); + background: var(--surface-soft); } .settings-menu-enter-active, .settings-menu-leave-active { - transition: opacity 0.18s ease, transform 0.18s ease; + transition: + opacity 0.18s ease, + transform 0.18s ease; } .settings-menu-enter-from, .settings-menu-leave-to { - opacity: 0; - transform: translateY(6px); + opacity: 0; + transform: translateY(6px); } /* 手机端审批面板样式 - 与电脑端保持一致 */ .mobile-approval-overlay { - pointer-events: auto; - justify-content: center !important; + pointer-events: auto; + justify-content: center !important; } .mobile-approval-overlay .mobile-panel-sheet--approval { - pointer-events: auto; - background: var(--claude-panel); + pointer-events: auto; + background: var(--claude-panel); } /* 手机端审批面板按钮样式 - 与电脑端一致 */ @media (max-width: 768px) { - .mobile-panel-sheet--approval .approval-btn { - border-radius: 8px; - padding: 8px 14px; - font-size: 13px; - } + .mobile-panel-sheet--approval .approval-btn { + border-radius: 8px; + padding: 8px 14px; + font-size: 13px; + } - .mobile-panel-sheet--approval .approval-btn--approve { - background: var(--claude-accent); - color: var(--on-accent); - border-color: var(--claude-accent); - } + .mobile-panel-sheet--approval .approval-btn--approve { + background: var(--claude-accent); + color: var(--on-accent); + border-color: var(--claude-accent); + } - .mobile-panel-sheet--approval .approval-btn--reject { - background: transparent; - border-color: var(--theme-control-border-strong); - } + .mobile-panel-sheet--approval .approval-btn--reject { + background: transparent; + border-color: var(--theme-control-border-strong); + } - .mobile-panel-sheet--approval .approval-btn--switch { - background: transparent; - border-color: var(--claude-accent); - color: var(--claude-accent); - } + .mobile-panel-sheet--approval .approval-btn--switch { + background: transparent; + border-color: var(--claude-accent); + color: var(--claude-accent); + } - /* 审批卡片样式保持一致 */ - .mobile-panel-sheet--approval .approval-card { - border-color: var(--theme-control-border); - background: var(--theme-surface-soft); - } + /* 审批卡片样式保持一致 */ + .mobile-panel-sheet--approval .approval-card { + border-color: var(--theme-control-border); + background: var(--theme-surface-soft); + } - /* 审批面板样式 - 移除背景色让内容顶头 */ - .mobile-panel-sheet--approval .sidebar.right-sidebar { - background: transparent !important; - } + /* 审批面板样式 - 移除背景色让内容顶头 */ + .mobile-panel-sheet--approval .sidebar.right-sidebar { + background: transparent !important; + } - .mobile-panel-sheet--approval .sidebar-header, - .mobile-panel-sheet--approval .sidebar .sidebar-header { - background: transparent !important; - border-color: var(--claude-border); - padding: 0 16px !important; - } + .mobile-panel-sheet--approval .sidebar-header, + .mobile-panel-sheet--approval .sidebar .sidebar-header { + background: transparent !important; + border-color: var(--claude-border); + padding: 0 16px !important; + } - /* 移动端审批面板关闭按钮 - 移除圆形外框 */ - .mobile-panel-sheet--approval .approval-close-btn { - background: transparent !important; - border-radius: 0 !important; - width: auto !important; - height: auto !important; - padding: 4px !important; - font-size: 24px !important; - } + /* 移动端审批面板关闭按钮 - 移除圆形外框 */ + .mobile-panel-sheet--approval .approval-close-btn { + background: transparent !important; + border-radius: 0 !important; + width: auto !important; + height: auto !important; + padding: 4px !important; + font-size: 24px !important; + } } - - - .user-question-mini-dot { - position: absolute; - right: 22px; - bottom: calc(100% + 6px); - z-index: 12; - min-width: 18px; - height: 18px; - padding: 0 5px; - border: 1.5px solid var(--theme-surface-strong); - border-radius: 999px; - background: var(--mac-close); - color: var(--on-accent); - cursor: pointer; - font-size: 11px; - line-height: 15px; - font-weight: 700; - display: inline-flex; - align-items: center; - justify-content: center; + position: absolute; + right: 22px; + bottom: calc(100% + 6px); + z-index: 12; + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 1.5px solid var(--theme-surface-strong); + border-radius: 999px; + background: var(--mac-close); + color: var(--on-accent); + cursor: pointer; + font-size: 11px; + line-height: 15px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; }