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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 09:19:54 +08:00
parent 8179a99967
commit ae32599ba8
6 changed files with 205 additions and 91 deletions

View File

@ -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<string, boolean>()
const TraceSectionScopeContext = createContext('default')
export function resetTraceSectionState(): void {
sectionOpenState.clear()
}
export function TraceSectionStateProvider({
scopeId,
children,
}: {
scopeId: string
children: ReactNode
}) {
return (
<TraceSectionScopeContext.Provider value={scopeId}>
{children}
</TraceSectionScopeContext.Provider>
)
}
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
})
}

View File

@ -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(<TraceList />)
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')

View File

@ -220,7 +220,7 @@ function TraceRows({
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y divide-[var(--color-border)]">
<div className="divide-y divide-[var(--color-border)]" role="list">
{traces.map((trace) => (
<TraceRow key={trace.sessionId} trace={trace} onOpenWindow={onOpenWindow} />
))}
@ -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<HTMLDivElement>) => {
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
open()
@ -261,54 +261,59 @@ function TraceRow({
return (
<div
role="button"
tabIndex={0}
onClick={open}
onKeyDown={onKeyDown}
role="listitem"
aria-label={title}
className="group flex h-14 cursor-pointer items-center gap-4 px-5 transition-colors hover:bg-[var(--color-surface-hover)]"
style={ROW_CV_STYLE}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
{visibleModels.map((model) => (
<span
key={model.model}
title={`${model.model} x${model.calls}`}
className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-brand)]/10 px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-brand)]"
>
{shortModelName(model.model)}
</span>
))}
{hiddenModels > 0 && (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-text-tertiary)]">
+{hiddenModels}
</span>
)}
{failedCalls > 0 && (
<span title={t('trace.failedCalls')} className="flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-error)]" aria-hidden="true" />
<span className="font-mono text-[10px] text-[var(--color-error)]">{failedCalls}</span>
</span>
)}
<button
type="button"
onClick={open}
onKeyDown={onKeyDown}
className="flex min-w-0 flex-1 items-center gap-4 self-stretch bg-transparent p-0 text-left"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">{title}</span>
{visibleModels.map((model) => (
<span
key={model.model}
title={`${model.model} x${model.calls}`}
className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-brand)]/10 px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-brand)]"
>
{shortModelName(model.model)}
</span>
))}
{hiddenModels > 0 && (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[10px] leading-4 text-[var(--color-text-tertiary)]">
+{hiddenModels}
</span>
)}
{failedCalls > 0 && (
<span title={t('trace.failedCalls')} className="flex shrink-0 items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-error)]" aria-hidden="true" />
<span className="font-mono text-[10px] text-[var(--color-error)]">{failedCalls}</span>
</span>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono">{trace.sessionId.slice(0, 8)}</span>
{trace.session?.projectPath && (
<>
<span aria-hidden="true">·</span>
<span className="truncate" title={trace.session.projectPath}>{trace.session.projectPath}</span>
</>
)}
<span aria-hidden="true">·</span>
<span className="shrink-0 font-mono">{formatUpdatedAt(updatedAt)}</span>
</div>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono">{trace.sessionId.slice(0, 8)}</span>
{trace.session?.projectPath && (
<>
<span aria-hidden="true">·</span>
<span className="truncate" title={trace.session.projectPath}>{trace.session.projectPath}</span>
</>
)}
<span aria-hidden="true">·</span>
<span className="shrink-0 font-mono">{formatUpdatedAt(updatedAt)}</span>
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.duration')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</div>
<div className="grid shrink-0 grid-cols-[3.5rem_4rem_4rem] items-center gap-3">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.duration')} value={formatDuration(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatCompact(totalTokens)} />
</div>
</button>
<div className="flex w-[60px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<RowAction
label={t('trace.open')}

View File

@ -6,7 +6,7 @@ import { sessionsApi } from '../api/sessions'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
import { clearTraceCallCache } from '../lib/trace/callCache'
import { resetTraceSectionState } from '../components/trace/detail/Section'
import { Section, resetTraceSectionState } from '../components/trace/detail/Section'
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceSession as TraceSessionData } from '../types/trace'
@ -348,19 +348,67 @@ describe('TraceSession', () => {
})
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(
<Section key="left" scopeId="left" sectionKey="llm.raw" title="Raw" defaultOpen>
left body
</Section>,
)
fireEvent.click(screen.getByRole('button', { name: /Raw/ }))
expect(screen.queryByText('left body')).not.toBeInTheDocument()
rerender(
<Section key="right" scopeId="right" sectionKey="llm.raw" title="Raw" defaultOpen>
right body
</Section>,
)
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()

View File

@ -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({
<DiagnosisBanner viewModel={viewModel} onSelect={setSelectedId} />
{hasTraceContent && activeSpan ? (
<div className="flex min-h-0 flex-1 flex-col border-t border-[var(--color-border)]">
<TraceSplitLayout
tree={
<TraceTree
viewModel={viewModel}
selectedId={activeSpan.id}
onSelect={setSelectedId}
/>
}
detail={
<TraceDetail
span={activeSpan}
viewModel={viewModel}
sessionId={sessionId}
onSelect={setSelectedId}
/>
}
/>
<TraceSectionStateProvider scopeId={sessionId}>
<TraceSplitLayout
tree={
<TraceTree
viewModel={viewModel}
selectedId={activeSpan.id}
onSelect={setSelectedId}
/>
}
detail={
<TraceDetail
span={activeSpan}
viewModel={viewModel}
sessionId={sessionId}
onSelect={setSelectedId}
/>
}
/>
</TraceSectionStateProvider>
</div>
) : (
<TraceEmpty />
@ -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' &&

View File

@ -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',