perf: track execution duration by state and bound summary I/O
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user