mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
8179a99967
commit
ae32599ba8
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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' &&
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user