From eb037ccb7c057b54ae326863aeb065cd3e767ffb Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 22 Jul 2026 19:33:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(quickdock):=20=E4=BF=AE=E5=A4=8D=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E7=AA=97=E5=8F=A3=20live=20=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E3=80=81raw=20=E5=AF=B9=E8=B1=A1=E5=93=8D=E5=BA=94=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E5=88=B7=E6=96=B0=E8=AF=AF=E6=92=AD=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/chat/quickdock/FileWindow.vue | 18 ++++++-- .../chat/quickdock/RunnerWindow.vue | 46 +++++++++---------- .../components/chat/quickdock/TodoWindow.vue | 10 ++-- static/src/composables/useLegacySocket.ts | 20 ++++---- static/src/stores/file.ts | 8 +++- 5 files changed, 59 insertions(+), 43 deletions(-) diff --git a/static/src/components/chat/quickdock/FileWindow.vue b/static/src/components/chat/quickdock/FileWindow.vue index 8c1937f1..1169f4e1 100644 --- a/static/src/components/chat/quickdock/FileWindow.vue +++ b/static/src/components/chat/quickdock/FileWindow.vue @@ -121,7 +121,11 @@ function scrollElIntoView(el: HTMLElement): Promise { }); } -async function playEnter(row: Row) { +async function playEnter(row: Row, delay = 0) { + if (delay > 0) { + // 空→有场景:等窗口进入动画播完再开始(与待办窗口的时序一致) + await new Promise((resolve) => setTimeout(resolve, delay)); + } await nextTick(); const listEl = listRef.value; const index = rows.value.indexOf(row); @@ -156,18 +160,24 @@ watch( const existing = new Set(rows.value.map((row) => row.path)); const added = list.filter((item) => !existing.has(item.path)); if (added.length) { - if (live && !windowEnterShown && !rows.value.length) { + const playWindow = live && !windowEnterShown && !rows.value.length; + if (playWindow) { windowEnterShown = true; windowEntering.value = true; setTimeout(() => { windowEntering.value = false; }, 320); } - added.forEach((item) => { + added.forEach((item, i) => { const row: Row = { path: item.path, entering: false, pendingEnter: live }; rows.value.push(row); if (live) { - void playEnter(row); + // 与待办窗口一致:窗口动画 0.3s 先播完,条目再按 stagger 60ms + // 逐个从左进入;窗口动画不重播时(非首次)条目直接依次进入。 + // 必须传 reactive 代理(rows.value 尾元素)而非上面的 raw 对象: + // 直接改 raw 不会 trigger 响应式更新,新行会永远卡在 opacity:0。 + const delay = (playWindow ? 300 : 0) + i * 60; + void playEnter(rows.value[rows.value.length - 1], delay); } }); } diff --git a/static/src/components/chat/quickdock/RunnerWindow.vue b/static/src/components/chat/quickdock/RunnerWindow.vue index 3b45c2e2..a59f877b 100644 --- a/static/src/components/chat/quickdock/RunnerWindow.vue +++ b/static/src/components/chat/quickdock/RunnerWindow.vue @@ -58,7 +58,6 @@ import { computed, nextTick, ref, watch } from 'vue'; import { storeToRefs } from 'pinia'; import { useSubAgentStore } from '@/stores/subAgent'; import { useBackgroundCommandStore } from '@/stores/backgroundCommand'; -import { useConversationStore } from '@/stores/conversation'; import { useQuickDockStore } from '@/stores/quickDock'; /** @@ -102,7 +101,6 @@ const props = defineProps<{ kind: 'agent' | 'cmd' }>(); const subAgentStore = useSubAgentStore(); const bgStore = useBackgroundCommandStore(); const quickDock = useQuickDockStore(); -const convStore = useConversationStore(); const { detail } = storeToRefs(quickDock); const rows = ref([]); @@ -110,16 +108,6 @@ const windowEntering = ref(false); const listRef = ref(null); let windowEnterShown = false; -/** 切换对话/页面加载后的第一次列表更新全部静态显示(不播进入动画); - * 之后运行期间新出现的条目才播。初始 true 保证首帧加载即静态。 */ -const staticNext = ref(true); -watch( - () => convStore.currentConversationId, - () => { - staticNext.value = true; - } -); - interface SourceItem { id: string; name: string; @@ -221,7 +209,11 @@ function scrollElIntoView(el: HTMLElement): Promise { }); } -async function playEnter(row: Row) { +async function playEnter(row: Row, delay = 0) { + if (delay > 0) { + // 空→有场景:等窗口进入动画播完再开始(与待办窗口的时序一致) + await new Promise((resolve) => setTimeout(resolve, delay)); + } await nextTick(); const listEl = listRef.value; const index = rows.value.indexOf(row); @@ -245,11 +237,6 @@ async function playEnter(row: Row) { watch( sourceItems, (list) => { - // 消费「下次静态」标记:无论本次有无新增都消费, - // 保证切换对话后的第一次更新(包括空表)直接静态 - const isStatic = staticNext.value; - staticNext.value = false; - const byId = new Map(list.map((item) => [item.id, item])); // 更新现有行状态;移除已消失的行 @@ -262,28 +249,39 @@ watch( } }); - // 新增行:静态模式直接插入;否则隐身插入 → 滚动露出 → 播进入动画 + // 新增行:运行期新增播动画(隐身插入 → 滚动露出 → 从左进入); + // 加载回填 / 后端状态恢复直接静态插入。 const existingIds = new Set(rows.value.map((row) => row.id)); const added = list.filter((item) => !existingIds.has(item.id)); + // 数据自证:新增条目含 running 才是运行期新增;全是 idle/终态时是 + // 加载回填或后端快照恢复(如多智能体状态恢复)。新启动的条目创建时 + // 一定是 running,加载的历史实例只会是 idle/done/ended——不依赖 + // 「第一次更新」之类的时序锚点,任何空表/竞态/闪断都不会误播。 + const animate = added.some((item) => item.state === 'running'); if (added.length) { - if (!isStatic && !windowEnterShown && !rows.value.length) { + const playWindow = animate && !windowEnterShown && !rows.value.length; + if (playWindow) { windowEnterShown = true; windowEntering.value = true; setTimeout(() => { windowEntering.value = false; }, 320); } - added.forEach((item) => { + added.forEach((item, i) => { const row: Row = { id: item.id, name: item.name, state: item.state, entering: false, - pendingEnter: !isStatic + pendingEnter: animate }; rows.value.push(row); - if (!isStatic) { - void playEnter(row); + if (animate) { + // 与待办窗口一致:窗口动画 0.3s 先播完,条目再按 stagger 60ms + // 逐个从左进入;窗口动画不重播时(非首次)条目直接依次进入。 + // 必须传 reactive 代理而非 raw 对象(raw 修改不触发重渲染)。 + const delay = (playWindow ? 300 : 0) + i * 60; + void playEnter(rows.value[rows.value.length - 1], delay); } }); } diff --git a/static/src/components/chat/quickdock/TodoWindow.vue b/static/src/components/chat/quickdock/TodoWindow.vue index 63ca68dc..3669d4fa 100644 --- a/static/src/components/chat/quickdock/TodoWindow.vue +++ b/static/src/components/chat/quickdock/TodoWindow.vue @@ -125,7 +125,7 @@ async function playLeave() { await wait(current.length * LEAVE_STAGGER + LEAVE_DURATION + 20); } -function renderEntering(tasks: TodoTask[]) { +function renderEntering(tasks: TodoTask[], baseDelay = 0) { rows.value = tasks.map((t, i) => ({ key: `${t.index}:${t.title}`, title: t.title, @@ -133,11 +133,12 @@ function renderEntering(tasks: TodoTask[]) { entering: true, leaving: false, justDone: false, - enterDelay: i * ENTER_STAGGER, + // baseDelay:空→有场景让窗口进入动画先播完,条目再依次进入(顺序而非同时) + enterDelay: baseDelay + i * ENTER_STAGGER, leaveDelay: 0 })); // 用 setTimeout 而非 animationend:动画事件会冒泡造成干扰 - const total = tasks.length * ENTER_STAGGER + 340 + 30; + const total = baseDelay + tasks.length * ENTER_STAGGER + 340 + 30; const myGen = gen; setTimeout(() => { if (myGen !== gen) { @@ -238,7 +239,8 @@ watch( setTimeout(() => { windowEntering.value = false; }, 320); - renderEntering(newTasks); + // 窗口进入动画 0.3s 播完后条目再逐个进入(顺序播放) + renderEntering(newTasks, 300); } else { renderStatic(newTasks); } diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 58636491..93bf2658 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -737,24 +737,26 @@ export async function initializeLegacySocket(ctx: any) { if (data.conversation_id !== ctx.currentConversationId) { return; } - ctx.fileSetTodoList(data.todo_list || null); + // 实时事件必须带 live=true:socket 先于任务轮询到达, + // 若静态消费(live=false)会抢先把状态写掉,等轮询的 live + // 事件再到时已无差异,快捷窗口的进入/打勾动画永远播不出来。 + ctx.fileSetTodoList(data.todo_list || null, true); }); // 快捷窗口:本次对话编辑/创建文件记录更新 ctx.socket.on('edited_files_updated', (data) => { socketLog('收到编辑文件记录更新事件:', data); - if (!data) { + // 全局广播:与 todo_updated 同款过滤——事件必须属于当前对话; + // /new 等无对话场景直接丢弃(文件记录一定归属某个对话)。 + if (!data || !data.conversation_id) { return; } - // 全局广播带 conversation_id,只响应当前对话的更新 - if ( - data.conversation_id && - ctx.currentConversationId && - data.conversation_id !== ctx.currentConversationId - ) { + if (data.conversation_id !== ctx.currentConversationId) { return; } - useQuickDockStore().setEditedFiles(data.edited_files || []); + // 同 todo_updated:socket 是实时通道,带 live=true 让新增文件 + // 播进入动画并滚动露出(静态插入在 5 行可视区外用户看不见)。 + useQuickDockStore().setEditedFiles(data.edited_files || [], true); }); // 系统就绪 diff --git a/static/src/stores/file.ts b/static/src/stores/file.ts index 1bb5ffdd..962b8e2a 100644 --- a/static/src/stores/file.ts +++ b/static/src/stores/file.ts @@ -176,16 +176,20 @@ export const useFileStore = defineStore('file', { const response = await fetch(url); const data = await response.json(); if (data && data.success) { - this.todoList = data.data || null; + // 顺序同 setTodoList:先写 live 标记(REST 拉取一律静态),再写数据 this.todoListLive = false; + this.todoList = data.data || null; } } catch (error) { console.error('获取待办列表失败:', error); } }, setTodoList(payload: TodoList | null, live = false) { - this.todoList = payload; + // 必须先写 live 标记再写数据:TodoWindow 的 watch 是 flush:'sync', + // todoList 一赋值就同步触发并读取 todoListLive;顺序颠倒会让 watch + // 读到上一次调用残留的旧值,动画路径(划线/圆点弹跳)永远走不到。 this.todoListLive = live; + this.todoList = payload; }, showContextMenu(payload: { node: FileNode; event: MouseEvent }) { if (!payload || !payload.node) {