fix(quickdock): 修复快捷窗口 live 链路、raw 对象响应式与刷新误播动画
This commit is contained in:
parent
165bcdb264
commit
eb037ccb7c
@ -121,7 +121,11 @@ function scrollElIntoView(el: HTMLElement): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<Row[]>([]);
|
||||
@ -110,16 +108,6 @@ const windowEntering = ref(false);
|
||||
const listRef = ref<HTMLElement | null>(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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
// 系统就绪
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user