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.
This commit is contained in:
mesalogo
2026-09-23 01:00:18 +08:00
parent 598c68f955
commit f7541203e4
3 changed files with 47 additions and 18 deletions
@@ -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()
+19 -17
View File
@@ -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<string, ExecutionStats>())
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
}
}