From f7541203e434ffd79489946c2c501fd849c751e5 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 23 Sep 2026 01:00:18 +0800 Subject: [PATCH] fix: restore cached reply duration when switching conversations Keep execution statistics by conversation and project so returning to a viewed conversation immediately restores its reply duration while refreshing in the background. Preserve late-result isolation and clear failed scopes. Validation: 15 focused tests, typecheck and lint passed. Full suite: 4878 passed, 9 navigation settings assertion failures and 1 Agent package timeout outside this change. Release note: Switching back to a viewed conversation now immediately displays its cached cumulative Agent reply duration while refreshing the statistics. --- .../features/task-and-job/technical-design.md | 4 ++- src/renderer/src/use-execution-stats.test.tsx | 25 +++++++++++++ src/renderer/src/use-execution-stats.ts | 36 ++++++++++--------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/docs/features/task-and-job/technical-design.md b/docs/features/task-and-job/technical-design.md index 4e1140af..e76ecff4 100644 --- a/docs/features/task-and-job/technical-design.md +++ b/docs/features/task-and-job/technical-design.md @@ -14,7 +14,9 @@ worker 内按项目或会话范围缓存结果,最多 8 项、30 秒;每次 活动回复绕过缓存,事务内读取清空并绕过缓存,关闭连接清空缓存。写入提交后的下一次读取 会重新计算,覆盖本地消息、远程事件、任务删除和恢复。统计数据仍以查询快照时刻为准。 -Renderer 保留可见时 5 秒刷新以及 revision、focus、visibility 触发刷新;失败的范围显示 +Renderer 按会话及所属项目、项目分别保留内存统计快照,切回已查询范围时同步显示缓存, +同时后台刷新;首次打开未缓存范围仍等待查询,缓存不跨应用重启保留,也不在前端推算时长。 +保留可见时 5 秒刷新以及 revision、focus、visibility 触发刷新;失败的范围清除缓存并显示 不可用,旧范围的迟到结果丢弃。重复缓存结果保留对象引用,更新通过 `startTransition` 提交,避免无变化的轮询反复更新 App 的任务时长映射。任务面板使用 React `Activity` 保留状态并降低隐藏时的渲染优先级;面板工厂在该边界内部调用。浏览器和终端沿用原有挂载 diff --git a/src/renderer/src/use-execution-stats.test.tsx b/src/renderer/src/use-execution-stats.test.tsx index d2b1d253..ae7700d4 100644 --- a/src/renderer/src/use-execution-stats.test.tsx +++ b/src/renderer/src/use-execution-stats.test.tsx @@ -49,6 +49,31 @@ afterEach(() => { }) describe('useExecutionStats', () => { + it.each(['conversation', 'project'] as const)( + 'restores cached statistics immediately when returning to a %s', async (scope) => { + const { result, rerender } = renderStats() + await act(async () => {}) + const cached = result.current + getExecutionStats.mockResolvedValue(stats(20)) + rerender({ + ...initialProps, conversationId: 'chat-b', + projectId: scope === 'project' ? 'project-b' : 'project-a' + }) + await act(async () => {}) + const conversation = deferred() + const project = deferred() + getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise) + rerender(initialProps) + expect(result.current.conversation).toBe(cached.conversation) + expect(result.current.project).toEqual(scope === 'project' ? cached.project : stats(20)) + await act(async () => { + conversation.resolve(stats(30)) + project.resolve(stats(40)) + }) + expect(result.current).toEqual({ conversation: stats(30), project: stats(40) }) + } + ) + it('retains snapshot references for identical cached polling results', async () => { getExecutionStats.mockImplementation(async () => stats(10)) const { result } = renderStats() diff --git a/src/renderer/src/use-execution-stats.ts b/src/renderer/src/use-execution-stats.ts index e52994e7..3aa3c710 100644 --- a/src/renderer/src/use-execution-stats.ts +++ b/src/renderer/src/use-execution-stats.ts @@ -8,10 +8,9 @@ export function useExecutionStats( revision: string, enabled: boolean ): { conversation?: ExecutionStats; project?: ExecutionStats } { - const [snapshot, setSnapshot] = useState<{ - conversationId?: string; projectId?: string - conversation?: ExecutionStats; project?: ExecutionStats - }>({}) + const [snapshots, setSnapshots] = useState(() => new Map()) + const conversationKey = JSON.stringify([projectId, conversationId]) + const projectKey = JSON.stringify([projectId]) useEffect(() => { if (!enabled || !projectId) return let cancelled = false @@ -26,17 +25,20 @@ export function useExecutionStats( window.goodbuddy.tasks.getExecutionStats({ projectId }) ]) pending = false - if (!cancelled) startTransition(() => setSnapshot((current) => { - const next = { - conversationId, projectId, - conversation: conversation.status === 'fulfilled' ? conversation.value : undefined, - project: project.status === 'fulfilled' ? project.value : undefined - } + if (!cancelled) startTransition(() => setSnapshots((current) => { + if (cancelled) return current + const next = new Map(current) // IPC clones cached snapshots. Preserve references when their contents are unchanged // so idle polling does not rerender App and rebuild task duration maps. - return current.conversationId === conversationId && current.projectId === projectId && - JSON.stringify(current.conversation) === JSON.stringify(next.conversation) && - JSON.stringify(current.project) === JSON.stringify(next.project) ? current : next + for (const [key, result] of [[conversationKey, conversation], [projectKey, project]] as const) { + const value = result.status === 'fulfilled' ? result.value : undefined + if (JSON.stringify(current.get(key)) === JSON.stringify(value)) continue + if (value) next.set(key, value) + else next.delete(key) + } + return next.size === current.size && + next.get(conversationKey) === current.get(conversationKey) && + next.get(projectKey) === current.get(projectKey) ? current : next })) } void refresh() @@ -50,10 +52,10 @@ export function useExecutionStats( window.removeEventListener('focus', onFocus) document.removeEventListener('visibilitychange', onFocus) } - }, [conversationId, projectId, revision, enabled]) + }, [conversationId, projectId, conversationKey, projectKey, revision, enabled]) return { - conversation: enabled && snapshot.projectId === projectId && snapshot.conversationId === conversationId - ? snapshot.conversation : undefined, - project: enabled && snapshot.projectId === projectId ? snapshot.project : undefined + conversation: enabled && projectId && conversationId + ? snapshots.get(conversationKey) : undefined, + project: enabled && projectId ? snapshots.get(projectKey) : undefined } }