diff --git a/src/App.vue b/src/App.vue index ef86723..95cd342 100644 --- a/src/App.vue +++ b/src/App.vue @@ -31,16 +31,16 @@ -
+
-
⚠ {{ errorMsg }}
+
⚠ {{ errorMsg }}
+ 告知会话转入后台继续生成、托盘图标可随时唤起,复用 app-toast 形态 --> -
{{ trayNoticeMsg }}
+
{{ trayNoticeMsg }}
@@ -114,7 +114,7 @@ watch(() => projectStore.error, (err) => { }) // ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)── -// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用 +// 与错误 toast 同款 app-toast 形态,信息语义走 --info 变体底色,不与错误红混用 const trayNoticeMsg = ref('') let trayNoticeTimer: ReturnType | null = null let _unlistenTrayHideNotice: (() => void) | null = null @@ -353,20 +353,21 @@ onUnmounted(() => { /* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══ 容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。 - 子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */ -.df-toast-stack { + 子项 .app-toast 独立命名(2026-10-18 与 global.css .df-toast 顶部居中族撞名根治, + 不再依赖定位覆盖对冲),主题保持根级强信号形态(实底白字)。 */ +.app-toast-stack { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 8px; z-index: 9999; max-width: 90vw; pointer-events: none; } -.df-toast { +.app-toast { background: var(--df-danger); color: #fff; padding: 10px 18px; border-radius: var(--df-radius-sm); font-size: 13px; max-width: 90vw; - box-shadow: 0 4px 16px rgba(0,0,0,0.3); + box-shadow: var(--df-shadow-md); } .toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); } .toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); } -/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */ -.df-toast--info { background: var(--df-accent); } +/* 后台运行提示:同 app-toast 形态,信息语义用 accent 底色与错误红区分(实底需白字) */ +.app-toast--info { background: var(--df-accent); color: #fff; } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 5f064dd..70064cb 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -53,17 +53,9 @@
{{ t('aiChat.autoCompressed') }}
- -
- - {{ agenticProgressText }} -
+ + @@ -75,46 +67,10 @@ path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载 DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 --> - -
-
- {{ $t('aiChat.queueTitle', { n: activeQueue.length }) }} -
- - -
-
-
-
- - - -
-
-
+ + - - -
{{ toast.msg }}
+ + +
{{ toast.msg }}
- - -
- -
-
+ +
@@ -176,10 +118,13 @@ import DirAuthDialog from './ai/DirAuthDialog.vue' import { useConfirm } from '../composables/useConfirm' import { useToast } from '../composables/useToast' import { useProjectStore } from '../stores/project' -import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace' import { useAiContext } from '../composables/ai/useAiContext' import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow' -import type { AiMessage } from '../api/types' +import type { AiMessage, SkillInfo } from '../api/types' +// C4b 拆分(第五批): Agentic 进度条/待发送队列/快捷键速查 三自有区块抽至 ai-chat/ 子目录 +import AgenticProgressBar from './ai-chat/agentic-progress-bar.vue' +import QueuePanel from './ai-chat/queue-panel.vue' +import ShortcutsOverlay from './ai-chat/shortcuts-overlay.vue' const props = defineProps<{ detached?: boolean @@ -262,22 +207,7 @@ const messageListRef = ref | null>(null) // 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示) const pendingApprovalCount = computed(() => store.state.pendingApprovals.length) -// Agentic 循环进度条 -// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。 -const showAgenticProgress = computed(() => - store.state.streaming && store.state.agentRound > 0, -) -// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。 -// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议); -// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。 -// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。 -const agenticProgressText = computed(() => { - const pending = store.state.pendingApprovals.length - const total = collectTurnStats(turnMessages(store.state.messages)).total - return pending > 0 - ? t('ai.agenticProgress', { round: store.state.agentRound, total, pending }) - : t('ai.agenticProgressNoPending', { round: store.state.agentRound, total }) -}) +// C4b: Agentic 循环进度条(显示条件+文案 computed)已随 ai-chat/agentic-progress-bar.vue 迁出 /** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */ function scrollToFirstPending(): void { @@ -337,10 +267,7 @@ const projectsStore = useProjectStore() // 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。 watch(() => store.state.activeConversationId, () => { if (editingMsgId.value) cancelEdit() - // UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue - // 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到 - // 新会话同下标队列项上,随消息编辑态一并重置。 - if (editingQueueIdx.value !== null) cancelEditQueued() + // UX-P3: 队列编辑态重置已随 QueuePanel 迁出(子组件内自 watch activeConversationId)。 }) @@ -485,135 +412,23 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) { // (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互) // ── L2 发送韧性:排队超时检测 + 强制发送 ── - -/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。 - * 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示 - * 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */ -const activeQueue = computed(() => - store.state.queue.filter(q => q.conversationId === store.state.activeConversationId), -) +// (activeQueue 过滤视图已随 queue-panel.vue 子组件迁移,父侧死副本删除) // ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ── // 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更) -// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读 -// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。 -const nowTick = ref(Date.now()) -let queueTickTimer: ReturnType | null = null +// 队列超时横幅/强制发送/清空/等待时钟(V7 nowTick)已随 queue-panel.vue 子组件迁移 +// (2026-10-18 UI 重构批,子组件自带 interval 与清理),父侧死副本删除(vue-tsc TS6133; +// 旧父组件时钟在无消费方后仍每秒空转,一并消除)。 -/** 队首排队是否已超 30s(仅看当前活跃会话队列)。 - * UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed - * 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick - * (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */ -const queueTimedOut = computed(() => { - void nowTick.value - return store.isQueueTimedOut(store.state.activeConversationId) -}) - -watch( - () => activeQueue.value.length > 0, - (waiting) => { - if (waiting && queueTickTimer === null) { - nowTick.value = Date.now() - queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000) - } else if (!waiting && queueTickTimer !== null) { - clearInterval(queueTickTimer) - queueTickTimer = null - } - }, - { immediate: true }, -) - -/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */ -function formatWaitTime(totalSeconds: number): string { - return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}` -} - -/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */ -function queueItemWait(item: { enqueuedAt: number }): string { - return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000))) -} - -/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */ -async function handleForceSend() { - await store.tryForceSend(confirmDialog, store.state.activeConversationId) -} - -/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。 - * 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例 - * (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */ -async function handleClearQueue() { - const convId = store.state.activeConversationId - if (!convId) return - const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm')) - if (confirmed) store.clearQueue(convId) +/** QueuePanel emit fill-draft 的父侧转发:写入 ChatInput 草稿(拆分纪律:ChatInput ref 留父) */ +function onQueueFillDraft(text: string, skill: SkillInfo | null) { + chatInputRef.value?.fillDraft(text, skill) } // ── 队列消息编辑(只编 text)+ 立即发送(插队)── -/** 当前正在编辑的队列项 index;null=非编辑态 */ -const editingQueueIdx = ref(null) -/** 编辑态输入框文本(回车写回 / ESC 取消还原) */ -const editingQueueText = ref('') - -/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */ -function startEditQueued(idx: number) { - const item = activeQueue.value[idx] - if (!item) return - editingQueueIdx.value = idx - editingQueueText.value = item.text -} - -/** 取消编辑:还原非编辑态,不写回 */ -function cancelEditQueued() { - editingQueueIdx.value = null - editingQueueText.value = '' -} - -/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词, - * 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫, - * 收敛到本函数统一判。 */ -function onQueueEditKeydown(e: KeyboardEvent) { - if (e.isComposing || e.keyCode === 229) return - if (e.key === 'Enter') { - e.preventDefault() - saveEditQueued() - } else if (e.key === 'Escape') { - e.preventDefault() - cancelEditQueued() - } -} - -/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */ -function saveEditQueued() { - if (editingQueueIdx.value === null) return - store.editQueued(editingQueueIdx.value, editingQueueText.value) - editingQueueIdx.value = null - editingQueueText.value = '' -} - -/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */ -async function handleSendQueuedNow(idx: number) { - await store.sendQueuedNow(idx) -} - -/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。 - * cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op; - * 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本, - * 与队列原地编辑只保 text 同一取舍。 */ -async function backfillQueued(idx: number) { - const item = activeQueue.value[idx] - if (!item) return - // UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖, - // 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。 - if (chatInputRef.value?.hasUnsentInput()) { - if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return - } - const skill = item.skill - ? store.state.skills.find(s => s.name === item.skill) ?? null - : null - store.cancelQueued(idx) - chatInputRef.value?.fillDraft(item.text, skill) -} +// 队列编辑态(编辑中 index/文本/进取消存取)已随 queue-panel.vue 子组件迁移(2026-10-18 +// UI 重构批),父组件死副本删除(vue-tsc TS6133);IME 守卫等行为在子组件内同名实现。 // 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随 // MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。 @@ -627,8 +442,7 @@ onBeforeUnmount(() => { // 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露) _listenerRelease?.() _listenerRelease = null - // V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清) - if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null } + // V7 排队时钟已随 queue-panel.vue 迁移,其 interval 由子组件 onBeforeUnmount 自清。 // 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏 window.removeEventListener('keydown', onGlobalKeydown) // 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload) @@ -786,16 +600,7 @@ async function toggleAlwaysOnTop() { // I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。 // 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。 const shortcutsVisible = ref(false) -// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构 -const shortcuts = computed(() => [ - { key: 'Ctrl+N', label: t('aiChat.scNewConv') }, - { key: 'Ctrl+I', label: t('aiChat.scFocusInput') }, - { key: 'Ctrl+R', label: t('aiChat.scRegenerate') }, - { key: 'Ctrl+L', label: t('aiChat.scClearChat') }, - { key: 'Ctrl+K', label: t('aiChat.scSearch') }, - { key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') }, - { key: 'Esc', label: t('aiChat.scCloseOverlay') }, -]) +// 键位表已随 shortcuts-overlay.vue 子组件迁移(C4b),父侧死副本删除(vue-tsc TS6133)。 function showShortcuts() { shortcutsVisible.value = true } @@ -902,7 +707,7 @@ async function initContextEventListeners(): Promise { 复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area 容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */ .ai-panel { - position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */ + position: relative; /* toast 已改全局 .df-toast(fixed 定位);relative 保留兜底其他绝对定位子元素 */ display: flex; flex-direction: row; height: 100%; @@ -924,7 +729,7 @@ async function initContextEventListeners(): Promise { TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享, 原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */ -/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */ +/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .df-toast--warning) ── */ .ai-auth-bypass-banner { padding: 5px 14px; background: var(--df-warning-bg); @@ -1054,27 +859,8 @@ async function initContextEventListeners(): Promise { /* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */ -/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */ -.ai-toast { - position: absolute; - top: 16px; - left: 50%; - transform: translateX(-50%); - padding: 8px 14px; - border-radius: var(--df-radius); - font-size: 12px; - z-index: 100; - box-shadow: 0 4px 16px rgba(0,0,0,0.3); - max-width: 80%; - overflow-wrap: anywhere; -} -.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); } -.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); } -.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); } -/* ToastType 新增 'success' 兜底样式 */ -.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); } -.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; } -.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); } +/* ═══ 错误 toast:消费 global.css .df-toast 四色族 + .df-toast-enter/leave 过渡 + (2026-10-18 整洁波双轨合一,原 .ai-toast-* 本地族删除;error 态映射 --danger) ═══ */ /* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效—— Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */ @@ -1091,7 +877,7 @@ async function initContextEventListeners(): Promise { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg, 8px); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + box-shadow: var(--df-shadow-sm); width: 320px; max-width: calc(100vw - 48px); padding: 14px 16px; @@ -1116,7 +902,7 @@ async function initContextEventListeners(): Promise { padding: 2px 4px; border-radius: 4px; } -.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); } +.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); } .ai-shortcuts-list { list-style: none; margin: 0; @@ -1132,9 +918,9 @@ async function initContextEventListeners(): Promise { font-size: 12px; } .ai-shortcuts-key { - font-family: var(--df-mono, monospace); + font-family: var(--df-font-mono); font-size: 11px; - background: var(--df-bg-secondary); + background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: 4px; padding: 2px 8px; diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue index 75b558a..1d833b3 100644 --- a/src/components/ToolCard.vue +++ b/src/components/ToolCard.vue @@ -21,9 +21,9 @@
- {{ toolDisplayName(tc) }} - {{ $t('aiTool.running') }} - {{ $t('aiTool.executionFailed') }} + {{ toolDisplayName(tc) }} + {{ $t('aiTool.running') }} + {{ $t('aiTool.executionFailed') }} {{ resultSummary }}
@@ -31,7 +31,7 @@ hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 --> {{ durationText }} ▸ @@ -56,8 +56,9 @@
- -
+ +
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
@@ -81,7 +82,7 @@
-
{{ ln.text }}{{ '\n' }}
+
{{ ln.text }}{{ '\n' }}
- + + {{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
@@ -26,8 +26,8 @@
{{ groupDisplayName(group) }} - - {{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }} + + {{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }} ▸
{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}
-
{{ ln.text }}{{ '\n' }}
+
{{ ln.text }}{{ '\n' }}
-
@@ -136,7 +136,7 @@
{{ parsed.warning }}
-
{{ ln.text }}{{ '\n' }}
+
{{ ln.text }}{{ '\n' }}
@@ -468,7 +468,7 @@ watch(() => props.tc.status, (s) => { } .ai-tool-grep-match { padding: 3px 12px; - border-bottom: 0.5px solid rgba(255,255,255,0.03); + border-bottom: 0.5px solid var(--df-hover); } .ai-tool-grep-match:last-child { border-bottom: none; @@ -511,7 +511,7 @@ watch(() => props.tc.status, (s) => { word-break: break-all; background: rgba(61,219,160,0.05); padding: 2px 6px; - border-radius: var(--df-radius-xs, 3px); + border-radius: var(--df-radius-xs); } .ai-tool-grep-context { margin: 2px 0 0; @@ -521,8 +521,8 @@ watch(() => props.tc.status, (s) => { font-family: var(--df-font-mono); font-size: 10px; color: var(--df-text-dim); - background: rgba(255,255,255,0.02); - border-radius: var(--df-radius-xs, 3px); + background: var(--df-hover); + border-radius: var(--df-radius-xs); } .ai-tool-grep-context code { display: block; @@ -533,7 +533,7 @@ watch(() => props.tc.status, (s) => { font-family: var(--df-font-sans); font-size: 10px; color: var(--df-text-dim); - background: rgba(255,255,255,0.04); + background: var(--df-hover); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); padding: 2px 8px; @@ -586,13 +586,13 @@ watch(() => props.tc.status, (s) => { .ai-tool-dir-entry { display: flex; align-items: center; - gap: 7px; + gap: 6px; padding: 3px 12px; font-size: 11px; transition: background 0.1s; } .ai-tool-dir-entry:hover { - background: rgba(255,255,255,0.03); + background: var(--df-hover); } .ai-tool-dir-entry--directory .ai-tool-dir-name { font-weight: 500; @@ -650,8 +650,8 @@ watch(() => props.tc.status, (s) => { padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px; - color: var(--df-warning, #d97706); - background: var(--df-warning-bg, rgba(217, 119, 6, 0.08)); + color: var(--df-warning); + background: var(--df-warning-bg); border-radius: var(--df-radius-sm, 4px); } .ai-tool-approval-diff { @@ -660,29 +660,11 @@ watch(() => props.tc.status, (s) => { border-radius: var(--df-radius-sm, 4px); overflow: hidden; } -.ai-tool-diff-pre { - margin: 0; - padding: 6px 0; - max-height: 240px; - overflow: auto; - font-family: var(--df-font-mono); - font-size: 11px; - line-height: 1.5; - background: var(--df-bg-card); -} -.ai-tool-diff-pre code { - display: block; - white-space: pre; -} -.ai-tool-diff-line { - display: block; - padding: 0 10px; - white-space: pre-wrap; - word-break: break-all; -} -.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); } -.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); } -.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); } +/* diff 基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义(2026-10-18 整洁波 + 双轨合一,与 ToolCard.vue 审批 diff 同源),此处仅留行配色变体 */ +.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); } +.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); } +.df-diff-line--ctx { color: var(--df-text-dim); } /* -- Generic Result -- max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性; @@ -741,12 +723,12 @@ watch(() => props.tc.status, (s) => { border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-dim); - font-size: 10.5px; + font-size: 10px; cursor: pointer; } .ai-tool-cmd-toggle:hover { color: var(--df-text); - background: var(--df-bg-hover, rgba(255,255,255,0.06)); + background: var(--df-hover); } .ai-tool-cmd-output { margin: 0; @@ -803,8 +785,8 @@ watch(() => props.tc.status, (s) => { } .ai-tool-http-trunc-warn { padding: 4px 10px; - font-size: 10.5px; - color: var(--df-warning, #f0c75e); + font-size: 10px; + color: var(--df-warning); border-top: 0.5px solid var(--df-border); } @@ -828,10 +810,10 @@ watch(() => props.tc.status, (s) => { } .ai-tool-fetch-mode--static, .ai-tool-fetch-mode--obscura { - color: var(--df-success, #3ddb9c); + color: var(--df-success); } .ai-tool-fetch-mode--static_fallback { - color: var(--df-warning, #f0c75e); + color: var(--df-warning); } .ai-tool-fetch-title { padding: 4px 10px 2px; @@ -845,8 +827,8 @@ watch(() => props.tc.status, (s) => { .ai-tool-obscura-hint { margin: 4px 10px 6px; padding: 6px 8px; - background: var(--df-warning-bg, rgba(240,199,94,0.08)); - border: 0.5px solid var(--df-warning, #f0c75e); + background: var(--df-warning-bg); + border: 0.5px solid var(--df-warning); border-radius: var(--df-radius-sm, 4px); display: flex; flex-direction: column; @@ -854,8 +836,8 @@ watch(() => props.tc.status, (s) => { } .ai-tool-obscura-hint-text { font-family: var(--df-font-sans); - font-size: 10.5px; - color: var(--df-warning, #f0c75e); + font-size: 10px; + color: var(--df-warning); line-height: 1.5; word-break: break-word; } @@ -870,7 +852,7 @@ watch(() => props.tc.status, (s) => { font-weight: 500; border-radius: var(--df-radius-sm, 4px); border: none; - background: var(--df-accent, #5b8def); + background: var(--df-accent); color: #fff; cursor: pointer; font-family: var(--df-font-sans); @@ -891,5 +873,5 @@ watch(() => props.tc.status, (s) => { padding: 3px 12px; cursor: pointer; } -.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); } +.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-card); } diff --git a/src/components/ai-chat/agentic-progress-bar.vue b/src/components/ai-chat/agentic-progress-bar.vue new file mode 100644 index 0000000..05bc83a --- /dev/null +++ b/src/components/ai-chat/agentic-progress-bar.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/ai-chat/queue-panel.vue b/src/components/ai-chat/queue-panel.vue new file mode 100644 index 0000000..de58ef3 --- /dev/null +++ b/src/components/ai-chat/queue-panel.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/src/components/ai-chat/shortcuts-overlay.vue b/src/components/ai-chat/shortcuts-overlay.vue new file mode 100644 index 0000000..8cdcaeb --- /dev/null +++ b/src/components/ai-chat/shortcuts-overlay.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/components/ai/ApprovalOverlay.vue b/src/components/ai/ApprovalOverlay.vue index 3b4455c..90ed9ca 100644 --- a/src/components/ai/ApprovalOverlay.vue +++ b/src/components/ai/ApprovalOverlay.vue @@ -108,7 +108,9 @@ function onDismiss() { diff --git a/src/components/ai/ConflictResolver.vue b/src/components/ai/ConflictResolver.vue index 9a4d47a..f896300 100644 --- a/src/components/ai/ConflictResolver.vue +++ b/src/components/ai/ConflictResolver.vue @@ -104,16 +104,16 @@ watch( diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index 6a97527..95d34b6 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -52,6 +52,18 @@ @keydown="onItemKeydown($event, conv.id, true)" >
+ + {{ conv.title || t('aiChat.newConversation') }} - {{ pendingCountOf(conv.id) }} @@ -145,6 +156,16 @@ @keydown="onItemKeydown($event, conv.id, true)" >
+ + - - {{ pendingCountOf(conv.id) }} @@ -225,6 +244,8 @@ @keydown.space.prevent="onRecover(conv.id)" >
+ + {{ conv.title || t('aiChat.newConversation') }} ⏸ @@ -260,9 +281,18 @@ @keydown="onItemKeydown($event, conv.id)" >
+ + {{ conv.title || t('aiChat.newConversation') }} - {{ pendingCountOf(conv.id) }} @@ -296,6 +326,17 @@
+ +
{{ convListPaging.loading ? t('common.loading') : t('gitChanges.loadMore') }}
+
{{ t('aiChat.emptyConversation') }}
@@ -338,7 +379,8 @@ import { convPendingApprovals } from '../../composables/ai/aiShared' // 镜像由边界配置卡在切会话/写入时渐进填充,无批量 IPC 属契约限制) import { getConvBoundary, fetchConvBoundary } from '../../composables/ai/useTaskBoundary' // UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast -import { convOpFailNotice } from '../../composables/ai/useAiConversations' +// F4-1: 会话列表分页游标(reactive,绑「加载更多」显隐/loading)+ 续拉/拉满动作 +import { convOpFailNotice, convListPaging, loadMoreConversations, loadAllConversations } from '../../composables/ai/useAiConversations' import { useToast } from '../../composables/useToast' import { formatRelative } from '../../utils/time' import type { AiConversationSummary } from '../../api/types' @@ -662,6 +704,28 @@ const searchResults = computed( () => store.filteredConversations.value, ) +// ── F4-1: 会话列表分页(加载更多按钮 + 搜索全量兜底) ── + +/** 「加载更多」:续拉下一页(失败/无新增静默,按钮 loading 态由 convListPaging 驱动) */ +function onLoadMore(): void { + void loadMoreConversations() +} + +// 搜索输入即拉满:搜索过滤(stores.filteredConversations)跑在已加载列表上,未拉满时 +// 第 51 条起的会话搜不到(硬 50 截断=搜索失灵)——输入非空且还有未拉页时一次拉尽, +// 之后搜索自然覆盖全量;已拉满(hasMore=false)零开销不触发。 +watch(() => store.state.searchQuery, (q) => { + if (q.trim() && convListPaging.hasMore && !convListPaging.loading) { + void loadAllConversations() + } +}) + +// 加载更多/搜索拉满追加的新会话补拉边界徽标镜像(A10 空洞随列表加深扩大; +// hydrate 对已入镜像的会话跳过,常规刷新时零 IPC) +watch(() => store.state.conversations.length, () => { + void hydrateBoundaryBadges() +}) + // ── 侧栏宽度拖拽 ── const resizeState = { startX: 0, startWidth: 0 } function onResizeMove(e: MouseEvent) { @@ -756,7 +820,7 @@ defineExpose({ transition: background 0.15s var(--df-ease); } .ai-sidebar-resize-handle:hover { - background: var(--df-accent, #4f8cff); + background: var(--df-accent); opacity: 0.5; } /* 拖拽中全局锁 cursor + 禁选中文本(挂在 body 上,跨元素生效) */ @@ -778,13 +842,21 @@ body.ai-sidebar-resizing * { color: var(--df-text); } -/* UX-06 §3.1: 对话搜索框(紧贴 header 下,样式跟随周围输入控件) */ +/* UX-06 §3.1: 对话搜索框(wave2 u-agent .sb-search 范式:容器成框,卡底+focus 边框 info) */ .ai-conv-search { position: relative; display: flex; align-items: center; - padding: 6px 10px; - border-bottom: 0.5px solid var(--df-border); + margin: 8px 10px; + padding: 0 8px; + height: 30px; + background: var(--df-bg-card); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-sm); + transition: border-color 0.15s var(--df-ease); +} +.ai-conv-search:focus-within { + border-color: var(--df-info); } .ai-conv-search-icon { flex: none; @@ -800,8 +872,8 @@ body.ai-sidebar-resizing * { font-size: 12px; color: var(--df-text); } -/* 聚焦可见环:无边框输入聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */ -.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; } +/* 聚焦可见环:容器已随 :focus-within 变 info 边框,键盘环同色补强(scoped 特异性须盖全局兜底) */ +.ai-conv-search-input:focus-visible { outline: 2px solid var(--df-info); outline-offset: 1px; } .ai-conv-search-input::placeholder { color: var(--df-text-dim); } @@ -819,12 +891,15 @@ body.ai-sidebar-resizing * { .ai-conv-list { flex: 1; overflow-y: auto; - padding: 4px; + padding: 4px 6px; /* u-agent .sb-list 范式;滚动条走 global ::-webkit-scrollbar token 体系 */ } +/* wave2 u-agent 范式: 分组头 10px uppercase + letter-spacing 1px + dim */ .ai-conv-group-title { - padding: 8px 8px 3px; - font-size: 11px; + padding: 10px 8px 4px; + font-size: 10px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; color: var(--df-text-dim); user-select: none; } @@ -851,7 +926,10 @@ body.ai-sidebar-resizing * { .ai-conv-group-count { margin-left: 2px; font-weight: 400; - opacity: 0.7; + font-family: var(--df-font-mono); /* mono 计数(df-mono 范式),upper/lsp 回退保证数字不散 */ + font-size: 10px; + letter-spacing: 0; + opacity: 0.9; } .ai-conv-group-empty { padding: 2px 8px 4px 20px; @@ -859,40 +937,47 @@ body.ai-sidebar-resizing * { color: var(--df-text-dim); font-style: italic; } +/* wave2 u-agent .cv 范式: 单行条目,8px 级密度;hover/active 同走灰阶卡底(GitHub 两级同底、 + active 由左 2px accent 实线区分),深浅主题同 token 自动适配 */ .ai-conv-item { display: flex; align-items: center; justify-content: space-between; - padding: 6px 8px; + padding: 8px; border-radius: var(--df-radius); cursor: pointer; transition: background 0.15s; - gap: 4px; + gap: 6px; } .ai-conv-item:hover { - background: var(--df-sidebar-hover); + background: var(--df-bg-card-hover); } .ai-conv-item--active { - background: var(--df-sidebar-active); + background: var(--df-bg-card-hover); + box-shadow: inset 2px 0 0 var(--df-accent); } /* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中"; IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */ .ai-conv-item--switching { - background: var(--df-sidebar-active); + background: var(--df-bg-card-hover); opacity: 0.7; } .ai-conv-item--archived .ai-conv-item-title { opacity: 0.6; } +/* wave2: 行内布局对齐 .cv-top([状态点][标题省略][时间]) */ .ai-conv-item-main { flex: 1; min-width: 0; display: flex; - flex-direction: column; - gap: 2px; + flex-direction: row; + align-items: center; + gap: 6px; } .ai-conv-item-title { - font-size: 13px; + flex: 1; + min-width: 0; + font-size: 12px; font-weight: 500; color: var(--df-text); white-space: nowrap; @@ -902,7 +987,6 @@ body.ai-sidebar-resizing * { display: inline-flex; align-items: center; gap: 3px; - min-width: 0; } /* UX-2025-20 §7.4: AI 生成标题淡入(0.3s opacity + transform 过渡) */ .ai-conv-item-title--flash { @@ -917,34 +1001,35 @@ body.ai-sidebar-resizing * { to { opacity: 1; transform: translateX(0); } } .ai-conv-item-edit { + flex: 1 1 0; /* wave2: main 已行化,输入框按 flex 填充(min-width:0 防 input 固有宽撑破窄侧栏) */ + min-width: 0; + width: auto; font-size: 11px; color: var(--df-text); - width: 100%; height: 18px; padding: 0 4px; border: 1px solid var(--df-accent); border-radius: var(--df-radius-sm); - background: var(--df-bg, #fff); + background: var(--df-bg); outline: none; box-sizing: border-box; } /* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */ .ai-conv-item-edit:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; } +/* wave2 u-agent 范式: 时间/计数元数据 mono 10px dim(.df-mono 同款视觉) */ .ai-conv-item-time { - font-size: 9px; + flex: none; + font-family: var(--df-font-mono); + font-size: 10px; color: var(--df-text-dim); } -/* 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */ -.ai-conv-gen-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--df-accent); - flex-shrink: 0; - animation: ai-conv-gen-pulse 1.4s ease-in-out infinite; -} +/* wave2 u-agent .st 状态点变体: 公共 .df-dot/.df-dot--run/.df-dot--stop 管造型与绿/灰底, + 此处只补动效与缺位色(全 token): run 绿+glow+脉冲(global df-pulse)、danger 待审批红、paused 停靠琥珀 */ +.ai-conv-status--run { box-shadow: 0 0 6px var(--df-success); animation: df-pulse 1.5s infinite; } +.ai-conv-status--danger { background: var(--df-danger); } +.ai-conv-status--paused { background: var(--df-warning); } @keyframes ai-conv-gen-pulse { + /* 供 .ai-conv-recover-dot 分组级脉冲用(原行内 gen-dot 已由状态点接管) */ 0%, 100% { opacity: 0.35; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } } @@ -955,7 +1040,7 @@ body.ai-sidebar-resizing * { height: 6px; border-radius: 50%; margin-right: 5px; - background: var(--df-warning, #f0c75e); + background: var(--df-warning); animation: ai-conv-gen-pulse 2s ease-in-out infinite; } .ai-conv-item--recoverable { @@ -966,7 +1051,7 @@ body.ai-sidebar-resizing * { margin-left: 2px; font-size: 10px; line-height: 1; - color: var(--df-warning, #f0c75e); + color: var(--df-warning); cursor: help; } /* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */ @@ -975,7 +1060,7 @@ body.ai-sidebar-resizing * { height: 12px; padding: 0 3px; border-radius: 6px; - background: var(--df-warning, #f0c75e); + background: var(--df-warning); color: #000; font-size: 9px; font-weight: 600; @@ -1032,9 +1117,13 @@ body.ai-sidebar-resizing * { color: var(--df-text); } /* 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。 - pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */ + pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 + wave2: 选中态同占左缘,置顶线降为 45% 透明 accent 让位,选中实线优先 */ .ai-conv-item--pinned { - box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text)); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--df-accent) 45%, transparent); +} +.ai-conv-item--active.ai-conv-item--pinned { + box-shadow: inset 2px 0 0 var(--df-accent); } /* ── 「更多」操作菜单(置顶/归档/删除)── 「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。 @@ -1056,8 +1145,8 @@ body.ai-sidebar-resizing * { display: flex; flex-direction: column; gap: 1px; - background: var(--df-bg, #fff); - border: 1px solid var(--df-border, #e2e2e2); + background: var(--df-bg); + border: 1px solid var(--df-border); border-radius: var(--df-radius-sm); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } @@ -1088,6 +1177,21 @@ body.ai-sidebar-resizing * { font-size: 11px; color: var(--df-text-dim); } +/* F4-1: 列表底部「加载更多」(轻量文字行,hover 同列表项灰阶卡底范式) */ +.ai-conv-load-more { + margin: 4px 2px; + padding: 6px 8px; + border-radius: var(--df-radius); + font-size: 11px; + color: var(--df-text-dim); + text-align: center; + cursor: pointer; + user-select: none; +} +.ai-conv-load-more:hover { + background: var(--df-bg-card-hover); + color: var(--df-text); +} /* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast, 但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */ @@ -1102,7 +1206,7 @@ body.ai-sidebar-resizing * { z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */ max-width: 85%; overflow-wrap: anywhere; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + box-shadow: var(--df-shadow-sm); background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); diff --git a/src/components/ai/DirAuthDialog.vue b/src/components/ai/DirAuthDialog.vue index 621845a..597707f 100644 --- a/src/components/ai/DirAuthDialog.vue +++ b/src/components/ai/DirAuthDialog.vue @@ -154,12 +154,12 @@ watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => { border-radius: var(--df-radius); } .ai-dir-auth-text { - font-size: 11.5px; + font-size: 11px; font-weight: 600; color: var(--df-warning); } .ai-dir-auth-hint { - font-size: 10.5px; + font-size: 10px; color: var(--df-text-dim); opacity: 0.9; word-break: break-all; diff --git a/src/components/ai/EmptyState.vue b/src/components/ai/EmptyState.vue index f881768..fb4e915 100644 --- a/src/components/ai/EmptyState.vue +++ b/src/components/ai/EmptyState.vue @@ -34,7 +34,7 @@ const examplePrompts = [