fix(desktop): keep an open trace record still while other spans arrive

A session-wide revision cleared the detail under the cursor every time a sibling span landed.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-22 02:57:34 +08:00
parent b5a6af8966
commit b058726312
7 changed files with 115 additions and 11 deletions
+6 -1
View File
@@ -16,12 +16,14 @@ export function TraceDetail({
viewModel,
sessionId,
revisionKey,
overviewRevisionKey,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
revisionKey?: string
overviewRevisionKey?: string
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
@@ -51,6 +53,7 @@ export function TraceDetail({
viewModel={viewModel}
sessionId={sessionId}
revisionKey={revisionKey}
overviewRevisionKey={overviewRevisionKey}
onSelect={onSelect}
/>
</div>
@@ -64,12 +67,14 @@ function DetailBody({
viewModel,
sessionId,
revisionKey,
overviewRevisionKey,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
revisionKey?: string
overviewRevisionKey?: string
onSelect: (spanId: string) => void
}) {
switch (span.kind) {
@@ -89,7 +94,7 @@ function DetailBody({
viewModel={viewModel}
onSelect={onSelect}
sessionId={sessionId}
revisionKey={revisionKey}
revisionKey={overviewRevisionKey ?? revisionKey}
/>
)
}
@@ -44,7 +44,8 @@ export function LlmCallDetail({
// second request; staleness is checked against the ref at resolve time.
if (fetchKeyRef.current === key) return
fetchKeyRef.current = key
setDetail(null)
// Keep the record on screen while a newer copy loads. Clearing it first
// drops the reader back to the truncated snapshot and resets their scroll.
void fetchTraceCallDetail(sessionId, callId, revisionKey).then((full) => {
if (fetchKeyRef.current !== key) return
if (full) {
@@ -41,10 +41,12 @@ function SessionRequestHeader({
}, [viewModel])
useEffect(() => {
// Clear first: without this, switching sessions keeps showing the previous
// session's system prompt until the new fetch resolves.
setHeader(null)
if (!sessionId || !firstCallId) return
if (!sessionId || !firstCallId) {
// Without this, switching to a session that has no call keeps showing the
// previous session's system prompt.
setHeader(null)
return
}
let cancelled = false
void fetchTraceCallDetail(sessionId, firstCallId, revisionKey).then((call) => {
if (cancelled) return
+1 -1
View File
@@ -116,7 +116,7 @@ describe('fetchTraceCallDetail', () => {
expect(traceCallRequests(fetchMock)).toHaveLength(2)
})
it('keeps same-revision terminal calls cached and refetches after the revision token changes', async () => {
it('keeps a call cached while its content key is unchanged and refetches when that key changes', async () => {
let version = 'old'
const fetchMock = mockFetch(() => jsonResponse({
call: makeCall({
+2
View File
@@ -12,6 +12,8 @@ export async function fetchTraceCallDetail(
callId: string,
revisionKey?: string,
): Promise<TraceCallRecord | null> {
// revisionKey is the call's own content, not the session revision. Callers pass
// the same key for an unchanged call, so a sibling span does not bust this cache.
const prefix = `${getBaseUrl()}\0${sessionId}\0${callId}\0`
const key = `${prefix}${revisionKey ?? 'legacy'}`
const cached = callCache.get(key)
+65 -3
View File
@@ -544,7 +544,9 @@ describe('TraceSession', () => {
expect(sessionsApi.getTrace).toHaveBeenCalledTimes(4)
expect(sessionsApi.getHistoryPage).toHaveBeenCalledTimes(2)
expect(screen.getByText('claude-sonnet-4-5 x2')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getTraceCall).mock.calls.length).toBeGreaterThan(1)
// The new span is a sibling. The call the reader has open did not change,
// so its detail must not be refetched.
expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(1)
const detail = within(screen.getByTestId('trace-detail'))
expect(detail.getByRole('heading', { level: 2, name: 'claude-sonnet-4-5' })).toBeInTheDocument()
} finally {
@@ -692,7 +694,66 @@ describe('TraceSession', () => {
expect(diagnosis.getByText('Model call failed')).toBeInTheDocument()
})
it('refetches a terminal call detail when the trace revision token changes', async () => {
it('keeps an open call detail stable when only a sibling span changes', async () => {
let resolveUpdatedRevision: ((revision: TraceSessionRevision) => void) | undefined
const updatedRevision = new Promise<TraceSessionRevision>((resolve) => {
resolveUpdatedRevision = resolve
})
const siblingCall = makeCall({
id: 'call-2',
startedAt: '2026-06-09T10:00:07.000Z',
completedAt: '2026-06-09T10:00:08.000Z',
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages',
headers: { 'content-type': 'application/json' },
body: {
contentType: 'json',
bytes: 12,
sha256: 'd'.repeat(64),
preview: '{"model":"claude-sonnet-4-5"}',
truncated: true,
},
},
})
vi.mocked(tracesApi.getRevision)
.mockResolvedValueOnce({
sessionId: SESSION_ID,
revision: 1,
revisionToken: 'epoch-a:1',
changed: true,
reset: false,
})
.mockReturnValue(updatedRevision)
vi.mocked(sessionsApi.getTrace)
.mockResolvedValueOnce(baseTrace)
.mockResolvedValue({
...baseTrace,
summary: { ...baseTrace.summary, apiCalls: 2 },
calls: [...baseTrace.calls, siblingCall],
})
await renderReady(20)
fireEvent.click(within(screen.getByTestId('trace-tree')).getAllByText('claude-sonnet-4-5')[0]!)
expect(await screen.findByText('Hi from the full record')).toBeInTheDocument()
const callsBeforeSibling = vi.mocked(sessionsApi.getTraceCall).mock.calls.length
resolveUpdatedRevision?.({
sessionId: SESSION_ID,
revision: 2,
revisionToken: 'epoch-a:2',
changed: true,
reset: false,
})
await waitFor(() => expect(sessionsApi.getTrace).toHaveBeenCalledTimes(2))
await waitFor(() => expect(within(screen.getByTestId('trace-tree')).getAllByText('claude-sonnet-4-5')).toHaveLength(2))
expect(screen.getByText('Hi from the full record')).toBeInTheDocument()
expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(callsBeforeSibling)
expect(screen.queryByText('Legacy truncated record; the semantic view is unavailable. See Raw below.')).not.toBeInTheDocument()
})
it('refetches a terminal call detail when that call content changes', async () => {
let resolveUpdatedRevision: ((revision: TraceSessionRevision) => void) | undefined
const updatedRevision = new Promise<TraceSessionRevision>((resolve) => {
resolveUpdatedRevision = resolve
@@ -741,6 +802,7 @@ describe('TraceSession', () => {
() => expect(screen.getByText('Hi from the full record')).toBeInTheDocument(),
{ timeout: 5_000 },
)
const callsBeforeUpdate = vi.mocked(sessionsApi.getTraceCall).mock.calls.length
resolveUpdatedRevision?.({
sessionId: SESSION_ID,
@@ -751,7 +813,7 @@ describe('TraceSession', () => {
})
await waitFor(
() => expect(sessionsApi.getTraceCall).toHaveBeenCalledTimes(2),
() => expect(sessionsApi.getTraceCall.mock.calls.length).toBeGreaterThan(callsBeforeUpdate),
{ timeout: 5_000 },
)
expect(await screen.findByText('Updated full record')).toBeInTheDocument()
+33 -1
View File
@@ -288,6 +288,14 @@ export function TraceSession({
const hasTraceContent = trace.calls.length > 0 || (trace.events?.length ?? 0) > 0 || messages.length > 0
const selectedSpan = selectedId ? viewModel.spansById.get(selectedId) : undefined
const activeSpan = selectedSpan ?? viewModel.spansById.get(viewModel.rootId) ?? viewModel.spans[0] ?? null
// An open record follows its own content, not the session revision. A sibling
// span arriving must not clear and refetch the record the reader is looking at.
const detailRevisionKey = spanContentKey(activeSpan)
// The overview reads the opening call, so it must use that call's content key.
// A different key misses the detail cache and refetches the same body.
const overviewRevisionKey = spanContentKey(
viewModel.spans.find((span) => span.kind === 'llm') ?? null,
)
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]">
@@ -331,7 +339,8 @@ export function TraceSession({
span={activeSpan}
viewModel={viewModel}
sessionId={sessionId}
revisionKey={revisionKey}
revisionKey={detailRevisionKey}
overviewRevisionKey={overviewRevisionKey}
onSelect={setSelectedId}
/>
}
@@ -345,6 +354,29 @@ export function TraceSession({
)
}
function spanContentKey(span: TraceSpan | null): string | undefined {
if (!span) return undefined
const call = span.call
if (call) {
return [
call.id,
call.status ?? span.status,
call.completedAt ?? '',
call.durationMs ?? '',
call.request.body.sha256,
call.response?.body.sha256 ?? '',
call.error?.name ?? '',
call.error?.message ?? '',
].join(':')
}
return [
span.id,
span.status,
span.completedAt ?? '',
span.durationMs ?? '',
].join(':')
}
function traceRevisionKey(revision: {
revision: number
revisionToken?: string