mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user