perf: track execution duration by state and bound summary I/O

This commit is contained in:
mesalogo
2026-10-04 16:21:55 +08:00
parent 2c2b6c31eb
commit 4d18794e74
31 changed files with 987 additions and 1397 deletions
+36 -2
View File
@@ -674,9 +674,10 @@ const api: DesktopApi & RuntimeNativeClientApi = {
tasks: {
list: vi.fn(async () => []),
getExecutionStats: vi.fn(async () => ({
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: Date.now(), taskDurations: [],
durationMs: 0, runningCount: 0, incomplete: false,
asOf: Date.now(), taskDurations: [],
})),
onExecutionStatsChanged: vi.fn(() => () => {}),
setStatus: vi.fn(async () => {}),
},
activityHistory: {
@@ -1437,6 +1438,10 @@ describe("App", () => {
};
});
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]);
vi.mocked(api.tasks.getExecutionStats).mockReset().mockImplementation(async () => ({
durationMs: 0, runningCount: 0, incomplete: false, asOf: Date.now(), taskDurations: [],
}));
vi.mocked(api.tasks.onExecutionStatsChanged).mockReset().mockReturnValue(() => {});
vi.mocked(api.activityHistory.get).mockReset().mockResolvedValue({
records: [],
legacyHistoryMayBeIncomplete: false,
@@ -7241,6 +7246,35 @@ describe("App", () => {
expect(runtimeButton).toBeEnabled();
});
it("renders authoritative duration snapshots and refreshes them from timing notifications", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValue([{
id: conversationId, projectId, title: "Timed conversation", updatedAt: Date.now(), messages: [],
}]);
const summary = { durationMs: 2000, runningCount: 0, incomplete: true, asOf: Date.now(), taskDurations: [] };
vi.mocked(api.tasks.getExecutionStats).mockResolvedValue(summary);
let changed: (() => void) | undefined;
const unsubscribe = vi.fn();
vi.mocked(api.tasks.onExecutionStatsChanged).mockImplementation((listener) => {
changed = listener;
return unsubscribe;
});
render(<App />);
await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(conversationId));
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
fireEvent.click(screen.getByRole("tab", { name: "任务中心" }));
const statistics = await screen.findByLabelText("当前会话统计");
expect(await within(statistics).findByText("00:00:02")).toBeVisible();
expect(within(statistics).getByText("部分回复缺少计时记录,时长仅包含已知记录。")).toBeVisible();
expect(api.tasks.getExecutionStats).toHaveBeenCalledWith({ conversationId });
expect(api.tasks.getExecutionStats).toHaveBeenCalledWith({ projectId });
vi.mocked(api.tasks.getExecutionStats).mockResolvedValue({ ...summary, durationMs: 65_000 });
act(() => changed?.());
expect(await within(statistics).findByText("00:01:05")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
await waitFor(() => expect(unsubscribe).toHaveBeenCalled());
});
it("associates live scheduled approvals using the dispatched task rather than another task in the conversation", async () => {
const conversationId = crypto.randomUUID();
const taskId = crypto.randomUUID();
+2 -5
View File
@@ -173,7 +173,6 @@ import {
useProductTasks,
useSidebarArtifacts,
useTaskActivityRows,
useTaskStatsRevision,
useTaskStatusRows,
} from "./task-selectors";
import {
@@ -2797,20 +2796,18 @@ function App(): React.JSX.Element {
// The supervisor page needs every task; App follows them only while it is shown.
const heartbeatTasks = useAllTasks(taskStore, view !== "heartbeat");
const executionStatsEnabled = assistantSidebarOpen && assistantSidebarTab === "tasks";
const executionStatsRevision = useTaskStatsRevision(taskStore, executionStatsEnabled);
const statsConversation = activeConversation?.projectId === activeProjectId
? activeConversation : undefined;
const statsMessageCount = statsConversation?.messageCount ?? 0;
const executionStats = useExecutionStats(
statsConversation?.id,
activeProjectId || undefined,
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
executionStatsEnabled,
);
const taskDurations = useMemo(() => new Map(
executionStats.project?.taskDurations.map((task) => [task.id, {
durationMs: task.durationMs,
incomplete: task.incompleteRequestCount > 0,
incomplete: task.incomplete,
}]) ?? [],
), [executionStats.project]);
const tasksByConversation = useMemo(() => {
@@ -6717,7 +6714,7 @@ function App(): React.JSX.Element {
);
const statsConversationId = statsConversation?.id;
const statsDurationMs = executionStats.conversation?.durationMs;
const statsIncomplete = (executionStats.conversation?.incompleteRequestCount ?? 0) > 0;
const statsIncomplete = executionStats.conversation?.incomplete ?? false;
const conversationStats = useMemo(
() =>
statsConversationId && statsDurationMs !== undefined
-11
View File
@@ -109,17 +109,6 @@ export function useTaskActivityRows(store: TaskStore): TaskActivityRow[] {
);
}
const selectStatsRevision = (tasks: AssistantTask[]): string =>
tasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|");
const selectNoRevision = (): string => "";
/**
* Changes whenever a task starts, finishes or changes status; constant while
* `enabled` is false, so nothing re-renders for statistics nobody sees.
*/
export function useTaskStatsRevision(store: TaskStore, enabled: boolean): string {
return useTaskSelector(store, enabled ? selectStatsRevision : selectNoRevision);
}
/** Whether a task of this conversation is running or waiting for approval. */
export function useConversationHasBusyTask(store: TaskStore, conversationId: string): boolean {
const select = useCallback(
-5
View File
@@ -7,7 +7,6 @@ import {
useProductTasks,
useSidebarArtifacts,
useTaskActivityRows,
useTaskStatsRevision,
useTaskStatusRows,
} from "./task-selectors";
import { createTaskStore, mergeArtifacts } from "./task-store";
@@ -103,7 +102,6 @@ describe("task selectors", () => {
status: useTaskStatusRows(store),
activity: useTaskActivityRows(store),
busy: useConversationHasBusyTask(store, "c1"),
revision: useTaskStatsRevision(store, false),
};
});
const first = result.current;
@@ -124,13 +122,10 @@ describe("task selectors", () => {
const { result } = renderHook(() => ({
busy: useConversationHasBusyTask(store, "c1"),
status: useTaskStatusRows(store),
revision: useTaskStatsRevision(store, true),
}));
const revision = result.current.revision;
act(() => store.setTasks((current) => current.map((item) => ({ ...item, status: "completed" }))));
expect(result.current.busy).toBe(false);
expect(result.current.status[0]!.status).toBe("completed");
expect(result.current.revision).not.toBe(revision);
});
it("keep the artifact map and sidebar entries while artifacts are unchanged", () => {
+68 -283
View File
@@ -1,299 +1,84 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
import type { ExecutionStats } from '../../shared/assistant-contracts'
import { useExecutionStats } from './use-execution-stats'
function stats(durationMs: number): ExecutionStats {
return {
durationMs, requestCount: 1, incompleteRequestCount: 0,
activeRequestCount: 0, asOf: 1_000, taskDurations: []
}
}
function deferred() {
let resolve!: (value: ExecutionStats) => void
let reject!: (reason: Error) => void
const promise = new Promise<ExecutionStats>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const empty = { conversation: undefined, project: undefined }
const initialProps = {
conversationId: 'chat-a' as string | undefined,
projectId: 'project-a' as string | undefined,
revision: '1', enabled: true
}
function renderStats() {
return renderHook(({ conversationId, projectId, revision, enabled }) =>
useExecutionStats(conversationId, projectId, revision, enabled), { initialProps })
}
const getExecutionStats = vi.fn<(input: ExecutionStatsInput) => Promise<ExecutionStats>>()
const snapshot = (runningCount = 0, durationMs = 1000): ExecutionStats => ({
durationMs, runningCount, incomplete: false, asOf: Date.now(),
taskDurations: [{ id: 'task', durationMs, runningCount, incomplete: false }]
})
const getExecutionStats = vi.fn()
const listeners = new Set<() => void>()
const flush = async (): Promise<void> => { await act(async () => { await Promise.resolve() }) }
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
getExecutionStats.mockReset().mockResolvedValue(stats(10))
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats } })
vi.setSystemTime(10_000)
getExecutionStats.mockReset().mockImplementation(async () => snapshot())
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats,
onExecutionStatsChanged: (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
} })
})
afterEach(() => { cleanup(); listeners.clear(); vi.useRealTimers(); vi.unstubAllGlobals() })
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
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()
await act(async () => {})
const before = result.current
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current.conversation).toBe(before.conversation)
expect(result.current.project).toBe(before.project)
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['project', 'conversation'] as const)(
'clears the previous %s selection and ignores its late polling result', async (scope) => {
const { result, rerender } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
const newConversation = deferred()
const newProject = deferred()
getExecutionStats.mockReturnValueOnce(newConversation.promise).mockReturnValueOnce(newProject.promise)
rerender({
...initialProps, conversationId: 'chat-b',
projectId: scope === 'project' ? 'project-b' : 'project-a'
})
expect(result.current).toEqual({
conversation: undefined, project: scope === 'project' ? undefined : stats(10)
})
expect(getExecutionStats.mock.calls.slice(-2)).toEqual([
[{ conversationId: 'chat-b' }],
[{ projectId: scope === 'project' ? 'project-b' : 'project-a' }]
])
await act(async () => {
newConversation.resolve(stats(20))
newProject.resolve(stats(30))
})
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
}
)
it('does not resurrect cancelled results when switching back to the same IDs', async () => {
const oldConversation = deferred()
const oldProject = deferred()
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
const { result, rerender } = renderStats()
rerender({ ...initialProps, conversationId: 'chat-b', projectId: 'project-b' })
await act(async () => {})
const currentConversation = deferred()
const currentProject = deferred()
getExecutionStats.mockReturnValueOnce(currentConversation.promise).mockReturnValueOnce(currentProject.promise)
rerender(initialProps)
await act(async () => {
oldConversation.resolve(stats(90))
oldProject.resolve(stats(99))
})
expect(result.current).toEqual(empty)
await act(async () => {
currentConversation.resolve(stats(20))
currentProject.resolve(stats(30))
})
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
})
it('does not overlap polling, focus or visibility refreshes while either query is pending', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
conversation.resolve(stats(20))
})
await act(async () => {
vi.advanceTimersByTime(5_000)
window.dispatchEvent(new Event('focus'))
})
describe('state-driven duration snapshots', () => {
it('ticks locally for concurrent running segments without polling or writes', async () => {
getExecutionStats.mockImplementation(async () => snapshot(2))
const { result } = renderHook(() => useExecutionStats('conversation', 'project', true))
await flush()
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual(empty)
await act(async () => { project.resolve(stats(30)) })
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
await act(async () => { vi.advanceTimersByTime(4_999) })
act(() => { vi.advanceTimersByTime(6000) })
expect(result.current.conversation?.durationMs).toBe(13_000)
expect(result.current.project?.taskDurations[0]?.durationMs).toBe(13_000)
expect(getExecutionStats).toHaveBeenCalledTimes(2)
await act(async () => { vi.advanceTimersByTime(1) })
getExecutionStats.mockImplementation(async () => snapshot(0, 13_000))
act(() => { listeners.forEach((listener) => listener()) })
await flush()
act(() => { vi.advanceTimersByTime(60_000) })
expect(result.current.conversation?.durationMs).toBe(13_000)
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it('replaces the poller on revision changes and removes timers and listeners on unmount', async () => {
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
const { result, rerender, unmount } = renderStats()
await act(async () => { vi.advanceTimersByTime(2_000) })
rerender({ ...initialProps, revision: '2' })
await act(async () => {})
expect(vi.getTimerCount()).toBe(1)
await act(async () => {
conversation.resolve(stats(90))
project.resolve(stats(99))
vi.advanceTimersByTime(3_000)
})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
await act(async () => { vi.advanceTimersByTime(2_000) })
expect(getExecutionStats).toHaveBeenCalledTimes(6)
const lastConversation = deferred()
const lastProject = deferred()
getExecutionStats.mockReturnValueOnce(lastConversation.promise).mockReturnValueOnce(lastProject.promise)
await act(async () => { window.dispatchEvent(new Event('focus')) })
unmount()
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
lastConversation.resolve(stats(50))
lastProject.resolve(stats(60))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(8)
})
it.each(['conversation', 'project', 'both'] as const)(
'clears stale statistics when %s queries fail and recovers on the next poll', async (failure) => {
const { result } = renderStats()
await act(async () => {})
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
getExecutionStats.mockImplementation(async (input) => {
if (failure === 'both' ||
(failure === 'conversation' && 'conversationId' in input) ||
(failure === 'project' && 'projectId' in input)) {
throw new Error('stats unavailable')
}
return stats(20)
})
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({
conversation: failure === 'project' ? stats(20) : undefined,
project: failure === 'conversation' ? stats(20) : undefined
})
getExecutionStats.mockResolvedValue(stats(30))
await act(async () => { vi.advanceTimersByTime(5_000) })
expect(result.current).toEqual({ conversation: stats(30), project: stats(30) })
}
)
it('does not query while hidden and resumes when visible', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const { result } = renderStats()
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).not.toHaveBeenCalled()
expect(result.current).toEqual(empty)
visibility.mockReturnValue('visible')
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
visibility.mockReturnValue('hidden')
await act(async () => {
vi.advanceTimersByTime(10_000)
window.dispatchEvent(new Event('focus'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(2)
visibility.mockReturnValue('visible')
await act(async () => { window.dispatchEvent(new Event('focus')) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
})
it.each(['disabled', 'no-project'] as const)('stops querying and hides results when %s', async (mode) => {
const { result, rerender } = renderStats()
await act(async () => {})
const conversation = deferred()
const project = deferred()
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
await act(async () => { vi.advanceTimersByTime(5_000) })
rerender({
...initialProps, enabled: mode !== 'disabled',
projectId: mode === 'no-project' ? undefined : initialProps.projectId
})
expect(result.current).toEqual(empty)
expect(vi.getTimerCount()).toBe(0)
await act(async () => {
conversation.resolve(stats(90))
project.reject(new Error('late failure'))
})
await act(async () => {
vi.advanceTimersByTime(15_000)
window.dispatchEvent(new Event('focus'))
document.dispatchEvent(new Event('visibilitychange'))
})
expect(getExecutionStats).toHaveBeenCalledTimes(4)
expect(result.current).toEqual(empty)
rerender(initialProps)
await act(async () => {})
expect(getExecutionStats).toHaveBeenCalledTimes(6)
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
})
it('queries only the project when no conversation is selected', async () => {
const { result, rerender } = renderStats()
await act(async () => {})
getExecutionStats.mockClear()
rerender({ ...initialProps, conversationId: undefined })
it('unsubscribes while hidden and gets authoritative snapshots on reopening', async () => {
const { result, rerender, unmount } = renderHook(({ enabled }) => useExecutionStats('c', 'p', enabled),
{ initialProps: { enabled: true } })
await flush()
rerender({ enabled: false })
expect(listeners.size).toBe(0)
expect(result.current.conversation).toBeUndefined()
await act(async () => {})
expect(getExecutionStats.mock.calls).toEqual([[{ projectId: 'project-a' }]])
expect(result.current).toEqual({ conversation: undefined, project: stats(10) })
act(() => { vi.advanceTimersByTime(60_000) })
expect(getExecutionStats).toHaveBeenCalledTimes(2)
getExecutionStats.mockImplementation(async () => snapshot(1, 60_000))
rerender({ enabled: true })
await flush()
expect(result.current.conversation?.durationMs).toBe(60_000)
unmount()
expect(listeners.size).toBe(0)
})
it('discards old scopes and in-flight snapshots superseded by a state change', async () => {
const pending: Array<(value: ExecutionStats) => void> = []
getExecutionStats.mockImplementation(() => new Promise<ExecutionStats>((resolve) => pending.push(resolve)))
const { result, rerender } = renderHook(({ id }) => useExecutionStats(id, 'p', true), { initialProps: { id: 'old' } })
rerender({ id: 'new' })
act(() => { listeners.forEach((listener) => listener()) })
await act(async () => { pending[4]!(snapshot(0, 9000)); pending[5]!(snapshot(0, 9000)) })
await act(async () => { for (const resolve of pending.slice(0, 4)) resolve(snapshot(1, 1000)) })
expect(result.current.conversation?.durationMs).toBe(9000)
expect(result.current.project?.runningCount).toBe(0)
})
it('does not tick idle snapshots and clears a failed scope', async () => {
const { result } = renderHook(() => useExecutionStats('c', 'p', true))
await flush()
act(() => { vi.advanceTimersByTime(60_000) })
expect(result.current.conversation?.durationMs).toBe(1000)
expect(getExecutionStats).toHaveBeenCalledTimes(2)
getExecutionStats.mockRejectedValue(new Error('unavailable'))
act(() => { listeners.forEach((listener) => listener()) })
await flush()
expect(result.current).toEqual({ conversation: undefined, project: undefined })
})
})
+34 -39
View File
@@ -1,61 +1,56 @@
import { startTransition, useEffect, useState } from 'react'
import type { ExecutionStats } from '../../shared/assistant-contracts'
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
export function useExecutionStats(
conversationId: string | undefined,
projectId: string | undefined,
revision: string,
enabled: boolean
): { conversation?: ExecutionStats; project?: ExecutionStats } {
const [snapshots, setSnapshots] = useState(() => new Map<string, ExecutionStats>())
const conversationKey = JSON.stringify([projectId, conversationId])
const projectKey = JSON.stringify([projectId])
const key = JSON.stringify([projectId, conversationId])
const [snapshot, setSnapshot] = useState<{
key: string; conversation?: ExecutionStats; project?: ExecutionStats
}>()
const [now, setNow] = useState(Date.now)
useEffect(() => {
if (!enabled || !projectId) return
let cancelled = false
let pending = false
let revision = 0
const refresh = async (): Promise<void> => {
if (pending || document.visibilityState === 'hidden') return
pending = true
const current = ++revision
const [conversation, project] = await Promise.allSettled([
conversationId
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
: Promise.resolve(undefined),
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
pending = false
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.
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
}))
if (cancelled || current !== revision) return
startTransition(() => {
if (cancelled || current !== revision) return
setNow(Date.now())
setSnapshot({ key,
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
project: project.status === 'fulfilled' ? project.value : undefined })
})
}
const unsubscribe = window.goodbuddy.tasks.onExecutionStatsChanged(() => { void refresh() })
void refresh()
const interval = window.setInterval(() => { void refresh() }, 5_000)
const onFocus = (): void => { void refresh() }
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
return () => {
cancelled = true
window.clearInterval(interval)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [conversationId, projectId, conversationKey, projectKey, revision, enabled])
return {
conversation: enabled && projectId && conversationId
? snapshots.get(conversationKey) : undefined,
project: enabled && projectId ? snapshots.get(projectKey) : undefined
return () => { cancelled = true; unsubscribe() }
}, [conversationId, projectId, key, enabled])
const current = enabled && snapshot?.key === key ? snapshot : undefined
const running = Boolean(current?.conversation?.runningCount || current?.project?.runningCount)
useEffect(() => {
if (!running) return
// Display only: no IPC or persistence on ticks, including in background tabs.
const interval = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(interval)
}, [running])
const display = (value: ExecutionStats | undefined): ExecutionStats | undefined => {
if (!value?.runningCount) return value
const elapsed = Math.max(0, now - value.asOf)
return { ...value, durationMs: value.durationMs + elapsed * value.runningCount,
taskDurations: value.taskDurations.map((task) => ({ ...task,
durationMs: task.durationMs + elapsed * task.runningCount })) }
}
return { conversation: display(current?.conversation), project: display(current?.project) }
}