From ae32599ba80d17f7e6e7f49a43da8c75f13d84df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 12 Jun 2026 09:19:54 +0800 Subject: [PATCH] fix(trace): stabilize live trace updates Keep live trace polling sensitive to in-place call changes, scope detail section collapse state by trace session, and expose trace rows with list/button semantics. Tested: cd desktop && bun run test -- TraceList.test.tsx TraceSession.test.tsx Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/trace-capture.test.ts Confidence: medium Scope-risk: narrow --- .../src/components/trace/detail/Section.tsx | 36 ++++--- desktop/src/pages/TraceList.test.tsx | 5 +- desktop/src/pages/TraceList.tsx | 95 ++++++++++--------- desktop/src/pages/TraceSession.test.tsx | 56 ++++++++++- desktop/src/pages/TraceSession.tsx | 82 +++++++++++----- src/server/__tests__/trace-capture.test.ts | 22 ++++- 6 files changed, 205 insertions(+), 91 deletions(-) diff --git a/desktop/src/components/trace/detail/Section.tsx b/desktop/src/components/trace/detail/Section.tsx index 3407f54a..3c870f73 100644 --- a/desktop/src/components/trace/detail/Section.tsx +++ b/desktop/src/components/trace/detail/Section.tsx @@ -1,17 +1,29 @@ -import { useEffect, useState, type ReactNode } from 'react' +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { ChevronRight } from 'lucide-react' -/** - * Collapse state is remembered per section key at module level so that - * switching the selected span keeps the user's reading layout intact. - */ const sectionOpenState = new Map() +const TraceSectionScopeContext = createContext('default') export function resetTraceSectionState(): void { sectionOpenState.clear() } +export function TraceSectionStateProvider({ + scopeId, + children, +}: { + scopeId: string + children: ReactNode +}) { + return ( + + {children} + + ) +} + export function Section({ + scopeId, sectionKey, title, badge, @@ -19,6 +31,7 @@ export function Section({ defaultOpen = false, children, }: { + scopeId?: string sectionKey: string title: string badge?: string | number @@ -26,17 +39,18 @@ export function Section({ defaultOpen?: boolean children: ReactNode }) { - const [open, setOpen] = useState(() => sectionOpenState.get(sectionKey) ?? defaultOpen) + const contextScopeId = useContext(TraceSectionScopeContext) + const resolvedScopeId = scopeId ?? contextScopeId + const stateKey = useMemo(() => `${resolvedScopeId}:${sectionKey}`, [resolvedScopeId, sectionKey]) + const [open, setOpen] = useState(() => sectionOpenState.get(stateKey) ?? defaultOpen) - // defaultOpen can flip after async detail loads (e.g. legacy fallback opens - // Raw). Follow it until the user toggles this section explicitly. useEffect(() => { - if (!sectionOpenState.has(sectionKey)) setOpen(defaultOpen) - }, [sectionKey, defaultOpen]) + setOpen(sectionOpenState.get(stateKey) ?? defaultOpen) + }, [stateKey, defaultOpen]) const toggle = () => { setOpen((previous) => { - sectionOpenState.set(sectionKey, !previous) + sectionOpenState.set(stateKey, !previous) return !previous }) } diff --git a/desktop/src/pages/TraceList.test.tsx b/desktop/src/pages/TraceList.test.tsx index 0d28cbf7..4abc4bc9 100644 --- a/desktop/src/pages/TraceList.test.tsx +++ b/desktop/src/pages/TraceList.test.tsx @@ -82,7 +82,7 @@ const secondTraceList: TraceSessionList = { } async function findTraceRow(title: RegExp) { - return await screen.findByRole('button', { name: title }) + return await screen.findByRole('listitem', { name: title }) } describe('TraceList', () => { @@ -139,7 +139,7 @@ describe('TraceList', () => { expect(useTabStore.getState().tabs.find((tab) => tab.type === 'trace')?.traceSessionId).toBe('session-trace-list') useTabStore.setState({ tabs: [], activeTabId: null }) - fireEvent.keyDown(await findTraceRow(/Debug stuck agent/), { key: 'Enter' }) + fireEvent.keyDown(within(await findTraceRow(/Debug stuck agent/)).getByRole('button', { name: /Debug stuck agent/ }), { key: 'Enter' }) expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list') }) @@ -148,6 +148,7 @@ describe('TraceList', () => { render() const row = await findTraceRow(/Debug stuck agent/) + expect(row).not.toHaveAttribute('role', 'button') fireEvent.click(within(row).getByRole('button', { name: 'Open in separate window' })) expect(openTraceWindowMock).toHaveBeenCalledWith('session-trace-list') diff --git a/desktop/src/pages/TraceList.tsx b/desktop/src/pages/TraceList.tsx index d434a880..2df2347d 100644 --- a/desktop/src/pages/TraceList.tsx +++ b/desktop/src/pages/TraceList.tsx @@ -220,7 +220,7 @@ function TraceRows({ return (
-
+
{traces.map((trace) => ( ))} @@ -253,7 +253,7 @@ function TraceRow({ const totalTokens = trace.summary.totalInputTokens + trace.summary.totalOutputTokens const open = () => openTrace(trace.sessionId, title, t) - const onKeyDown = (event: KeyboardEvent) => { + const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() open() @@ -261,54 +261,59 @@ function TraceRow({ return (
-
-
- {title} - {visibleModels.map((model) => ( - - {shortModelName(model.model)} - - ))} - {hiddenModels > 0 && ( - - +{hiddenModels} - - )} - {failedCalls > 0 && ( - - - )} +
{ }) await renderReady(20) - // Select the model call; identical poll ticks must not reset the selection - // or re-trigger the on-demand detail fetch. fireEvent.click(within(screen.getByTestId('trace-tree')).getByText('claude-sonnet-4-5')) await waitFor(() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1)) await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(3)) - // The grown snapshot lands and renders both calls. await screen.findByText('claude-sonnet-4-5 x2') expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1) const detail = within(screen.getByTestId('trace-detail')) expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument() }) + it('applies poll updates when a call changes without changing row counts', async () => { + const pendingTrace: TraceSessionData = { + ...baseTrace, + summary: { + ...baseTrace.summary, + failedCalls: 0, + updatedAt: '2026-06-09T10:00:01.000Z', + }, + calls: [makeCall({ status: 'pending', completedAt: undefined, durationMs: undefined, response: undefined, usage: undefined })], + } + const completedTrace: TraceSessionData = { + ...baseTrace, + summary: { + ...baseTrace.summary, + failedCalls: 1, + updatedAt: '2026-06-09T10:00:01.000Z', + }, + calls: [makeCall({ status: 'error', error: { name: 'Error', message: 'rate limited' }, response: undefined })], + } + vi.mocked(sessionsApi.getTrace) + .mockResolvedValueOnce(pendingTrace) + .mockResolvedValue(completedTrace) + + await renderReady(20) + + await waitFor(() => expect(vi.mocked(sessionsApi.getTrace).mock.calls.length).toBeGreaterThanOrEqual(2)) + const diagnosis = within(screen.getByTestId('trace-diagnosis')) + expect(diagnosis.getByText('error')).toBeInTheDocument() + expect(diagnosis.getByText('Model call failed')).toBeInTheDocument() + }) + + it('keeps section collapse state scoped to each trace session instance', async () => { + const { rerender } = render( +
+ left body +
, + ) + + fireEvent.click(screen.getByRole('button', { name: /Raw/ })) + expect(screen.queryByText('left body')).not.toBeInTheDocument() + + rerender( +
+ right body +
, + ) + + expect(screen.getByRole('button', { name: /Raw/ })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('right body')).toBeInTheDocument() + }) + it('supports keyboard navigation in the tree', async () => { await renderReady() diff --git a/desktop/src/pages/TraceSession.tsx b/desktop/src/pages/TraceSession.tsx index d8fcb5ba..68de54e2 100644 --- a/desktop/src/pages/TraceSession.tsx +++ b/desktop/src/pages/TraceSession.tsx @@ -19,6 +19,7 @@ import { CopyButton } from '../components/shared/CopyButton' import { TraceSplitLayout } from '../components/trace/TraceSplitLayout' import { TraceTree } from '../components/trace/TraceTree' import { TraceDetail } from '../components/trace/TraceDetail' +import { TraceSectionStateProvider } from '../components/trace/detail/Section' import { LiveBadge, MetaChip, StatusPill, TypeIcon, spanDisplayTitle } from '../components/trace/TraceBadges' import { buildTraceViewModel, @@ -62,19 +63,15 @@ export function TraceSession({ if (!silent) setState({ status: 'loading' }) if (silent) setRefreshing(true) try { - const trace = await sessionsApi.getTrace(sessionId) + const [trace, messageResponse] = await Promise.all([ + sessionsApi.getTrace(sessionId), + sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })), + ]) if (!isTraceSessionData(trace)) { throw new Error(t('trace.snapshotEmpty')) } - const messageResponse = await sessionsApi.getMessages(sessionId).catch(() => ({ messages: [] })) if (cancelled) return - const signature = [ - trace.summary.updatedAt ?? '', - trace.calls.length, - trace.events?.length ?? 0, - messageResponse.messages.length, - ].join('|') - // Poll short-circuit: identical snapshot, skip the viewModel rebuild. + const signature = traceSnapshotSignature(trace, messageResponse.messages) if (silent && snapshotSignatureRef.current === signature) return snapshotSignatureRef.current = signature setState({ status: 'ready', trace, messages: messageResponse.messages }) @@ -210,23 +207,25 @@ export function TraceSession({ {hasTraceContent && activeSpan ? (
- - } - detail={ - - } - /> + + + } + detail={ + + } + /> +
) : ( @@ -446,6 +445,37 @@ function TraceEmpty() { ) } +function traceSnapshotSignature(trace: TraceSessionData, messages: MessageEntry[]): string { + return JSON.stringify({ + summary: trace.summary, + calls: trace.calls.map((call) => ({ + id: call.id, + status: call.status, + completedAt: call.completedAt, + durationMs: call.durationMs, + usage: call.usage, + responseStatus: call.response?.status, + requestSha256: call.request.body.sha256, + responseSha256: call.response?.body.sha256, + error: call.error ? { name: call.error.name, message: call.error.message, code: call.error.code } : null, + })), + events: (trace.events ?? []).map((event) => ({ + id: event.id, + timestamp: event.timestamp, + phase: event.phase, + severity: event.severity, + callId: event.callId, + message: event.message, + })), + messages: messages.map((message) => ({ + id: message.id, + type: message.type, + timestamp: message.timestamp, + content: message.content, + })), + }) +} + function isTraceSessionData(value: unknown): value is TraceSessionData { return !!value && typeof value === 'object' && diff --git a/src/server/__tests__/trace-capture.test.ts b/src/server/__tests__/trace-capture.test.ts index 749059d5..c00f1976 100644 --- a/src/server/__tests__/trace-capture.test.ts +++ b/src/server/__tests__/trace-capture.test.ts @@ -918,12 +918,12 @@ describe('session trace API', () => { }) describe('trace read cache', () => { - function buildTraceCallLine(id: string): string { + function buildTraceCallLine(id: string, sessionId = 'session-cache-hit', payload = 'ok'): string { return `${JSON.stringify({ type: 'call', record: { id, - sessionId: 'session-cache-hit', + sessionId, source: 'proxy', status: 'ok', startedAt: '2026-06-09T08:00:00.000Z', @@ -933,7 +933,7 @@ describe('trace read cache', () => { method: 'POST', url: 'https://api.example.test/v1/chat/completions', headers: {}, - body: createTraceBodySnapshot({ model: 'gpt-5.5' }), + body: createTraceBodySnapshot({ model: 'gpt-5.5', payload }), }, response: { status: 200, @@ -974,6 +974,22 @@ describe('trace read cache', () => { expect(third.calls.map((call) => call.id)).toEqual(['call-bbb']) }) + test('stores trimmed records in the list cache and keeps full records for detail reads', async () => { + const traceDir = path.join(tmpDir, 'cc-haha', 'traces') + const filePath = path.join(traceDir, 'session-cache-list.jsonl') + await fs.mkdir(traceDir, { recursive: true }) + await fs.writeFile(filePath, buildTraceCallLine('call-list-cache', 'session-cache-list', 'x'.repeat(10_000))) + + const list = await traceCaptureService.listSessionTraces({ sessionIds: ['session-cache-list'] }) + expect(list.traces).toHaveLength(1) + + const trace = await traceCaptureService.getSessionTrace('session-cache-list') + const detail = await traceCaptureService.getSessionTraceCall('session-cache-list', 'call-list-cache') + + expect(trace.calls[0].request.body.preview.length).toBeGreaterThan(2048) + expect(detail?.request.body.preview.length).toBeGreaterThan(2048) + }) + test('invalidates the cache when new entries are appended in process', async () => { await traceCaptureService.recordCall({ id: 'call-cache-1',