feat(desktop): surface the system prompt and injected context in trace

A traced session already recorded everything needed to explain a model
request, but the presentation kept it out of reach. The system prompt lived
inside each individual call, so reading it meant picking a call first. The
context the harness assembles — CLAUDE.md, system-reminder blocks,
deferred-tool rosters — was indistinguishable from what the person typed,
because the provider receives all of it as user-role text.

- The session overview names the system prompt and tool catalog, read once
  from the first model call rather than hunted down per request. This is the
  opening header, not a session-wide invariant: late tool registration and a
  mid-session model change rewrite it for later requests, which keep their own
  header on their own detail.
- A model call's detail separates injected context from the exchange, one row
  per injection labelled by its own content instead of its wrapper tag.
- Tool rows carry their input summary and model rows their token counts, so
  scanning the tree distinguishes one call from the next.
- An assistant turn that only reasoned says so, including when the provider
  withheld the reasoning, instead of rendering as a bare label.

Recognition of injected context is by wrapper tag against a closed list.
Position cannot stand in for it: hoistToolResults moves every tool result to
the front of a merged user message, so an attachment the harness appended and
an instruction typed after interrupting a tool arrive in the same shape. The
ambiguity is resolved toward the conversation — unrecognized text stays the
person's message, because looking for what you said and not finding it is the
worse failure.

requestParse also learns the OpenAI Responses wire format, which about a
quarter of traced sessions use and which previously yielded an empty message
list and no system prompt. Responses splits a tool round trip into sibling
function_call / function_call_output entries; across 150 real trace files
those outnumber plain messages 1199 to 878, so they are mapped onto the
tool_use / tool_result vocabulary rather than skipped. Its flat tool
`parameters` and `instructions` spellings are read too, the latter with `||`
so an empty `system` array cannot shadow it.

All new surfaces use design tokens, so the six themes need no per-theme work.

Claude-Session: https://claude.ai/code/session_0119s8U5VzUvVpNgWA7B3pSg
This commit is contained in:
程序员阿江(Relakkes)
2026-08-24 03:25:09 +08:00
parent a834434284
commit 66206cd8a3
14 changed files with 819 additions and 18 deletions
+9 -1
View File
@@ -83,7 +83,15 @@ function DetailBody({
case 'event':
return <EventDetail span={span} />
default:
return <SessionOverview span={span} viewModel={viewModel} onSelect={onSelect} />
return (
<SessionOverview
span={span}
viewModel={viewModel}
onSelect={onSelect}
sessionId={sessionId}
revisionKey={revisionKey}
/>
)
}
}
+53 -5
View File
@@ -7,7 +7,7 @@ import { SearchField } from '@/components/ui/SearchField'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { useTranslation } from '../../i18n'
import { previewTraceValue, type TraceSpan, type TraceViewModel } from '../../lib/traceViewModel'
import { formatDurationMs } from '../../lib/trace/formatters'
import { formatDurationMs, formatTokenCount } from '../../lib/trace/formatters'
import { StatusGlyph, TypeIcon, spanDisplayTitle, turnDisplayTitle } from './TraceBadges'
export type TraceTreeFilter = 'all' | 'llm' | 'tool' | 'error'
@@ -207,8 +207,9 @@ function TurnGroup({
function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: boolean; onSelect: () => void }) {
const t = useTranslation()
const span = row.span
const preview = rowPreview(span)
const preview = rowPreview(span, t)
const duration = span.durationMs !== undefined ? formatDurationMs(span.durationMs) : null
const tokens = rowTokens(span)
return (
<button
@@ -242,6 +243,9 @@ function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: bo
</span>
) : null}
</span>
{tokens ? (
<span className="shrink-0 font-mono text-[11.5px] text-[var(--color-text-tertiary)]">{tokens}</span>
) : null}
{duration ? (
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-tertiary)]">{duration}</span>
) : null}
@@ -250,14 +254,58 @@ function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: bo
)
}
function rowPreview(span: TraceSpan): string | null {
if (span.kind === 'message' || span.kind === 'event') {
function rowPreview(span: TraceSpan, t: ReturnType<typeof useTranslation>): string | null {
// A tool row's subtitle is its summarized input, which is what distinguishes
// one Bash call from the next; without it every tool row reads the same.
if (span.kind === 'message' || span.kind === 'event' || span.kind === 'tool') {
const preview = span.subtitle
return preview && preview !== 'empty' ? preview : null
if (preview && preview !== 'empty') return preview
// An assistant turn that only reasoned has no extractable text, which would
// otherwise leave the row as a bare "Assistant" label with nothing on it.
return span.kind === 'message' ? reasoningPreview(span.message?.content, t) : null
}
return null
}
const REASONING_PREVIEW_MAX = 120
/**
* What an assistant turn did when it produced no visible text. Readable
* reasoning is previewed; provider reasoning the harness stores encoded is
* named rather than dumped, since its payload is not text a reader can use.
*/
function reasoningPreview(content: unknown, t: ReturnType<typeof useTranslation>): string | null {
if (!Array.isArray(content)) return null
let redacted = false
for (const block of content) {
if (!block || typeof block !== 'object') continue
const record = block as { type?: unknown; thinking?: unknown }
if (record.type === 'redacted_thinking') {
redacted = true
continue
}
if (record.type !== 'thinking' || typeof record.thinking !== 'string') continue
const text = record.thinking.replace(/\s+/g, ' ').trim()
if (text.length === 0) continue
return text.length > REASONING_PREVIEW_MAX ? `${text.slice(0, REASONING_PREVIEW_MAX)}…` : text
}
return redacted ? t('trace.message.redactedReasoning') : null
}
/**
* Compact token counts for a model call, so cost reads off the row itself.
* Counts only `inputTokens`, matching the detail header's `formatUsageBrief`
* and the session overview — a row that added cached tokens here would
* contradict both the moment prompt caching is on.
*/
function rowTokens(span: TraceSpan): string | null {
if (span.kind !== 'llm') return null
const usage = span.call?.usage
if (!usage) return null
if (usage.inputTokens === 0 && usage.outputTokens === 0) return null
return `${formatTokenCount(usage.inputTokens)}↑ ${formatTokenCount(usage.outputTokens)}↓`
}
function turnPreview(turnSpan: TraceSpan, t: ReturnType<typeof useTranslation>): string {
return turnDisplayTitle(turnSpan.title, (turnSpan.turnIndex ?? 0) + 1, t)
}
@@ -5,6 +5,7 @@ import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody, parseTraceResponseBody } from '../../../lib/trace/requestParse'
import { splitRequestMessages, type LocatedInjection } from '../../../lib/trace/semanticTimeline'
import type { NormalizedMessage } from '../../../lib/trace/types'
import { formatBytes } from '../../../lib/formatBytes'
import { CodeViewer } from '../../chat/CodeViewer'
@@ -68,6 +69,14 @@ export function LlmCallDetail({
}
}, [effectiveCall])
// The harness sends its assembled context as user-role text. Reading it
// beside the real exchange is what makes a request legible, so the two are
// split apart rather than rendered as one message list.
const split = useMemo(
() => splitRequestMessages(parsed.request?.messages ?? []),
[parsed.request],
)
if (!call || !effectiveCall) return null
const loadingDetail = isTerminal && (!detail || detail.id !== callId) && !fetchFailed
@@ -99,14 +108,25 @@ export function LlmCallDetail({
/>
</Section>
{parsed.request && parsed.request.messages.length > 0 ? (
{split.injections.length > 0 ? (
<Section
sectionKey="llm.context"
title={t('trace.section.context')}
badge={split.injections.length}
defaultOpen
>
<ContextInjectionList injections={split.injections} />
</Section>
) : null}
{split.conversation.length > 0 ? (
<Section
sectionKey="llm.messages"
title={t('trace.section.messages')}
badge={parsed.request.messages.length}
badge={split.conversation.length}
defaultOpen
>
<MessageList messages={parsed.request.messages} />
<MessageList messages={split.conversation} />
</Section>
) : null}
@@ -262,6 +282,57 @@ function MessageList({ messages }: { messages: NormalizedMessage[] }) {
)
}
const CONTEXT_KIND_LABEL = {
'system-reminder': 'trace.context.systemReminder',
'deferred-tools': 'trace.context.deferredTools',
other: 'trace.context.other',
} as const satisfies Record<LocatedInjection['kind'], string>
/**
* The context the harness assembled for this request, one row per injection.
* Collapsed rows carry the kind and size so a scan shows what was added
* without opening anything.
*/
function ContextInjectionList({ injections }: { injections: LocatedInjection[] }) {
const t = useTranslation()
// Keyed by content, not position: a live session re-renders on every poll and
// an index would then point at whichever injection landed in that slot.
const [expanded, setExpanded] = useState<string | null>(null)
return (
<div className="flex flex-col gap-1.5">
{injections.map((injection) => {
const key = `${injection.messageIndex}:${injection.kind}:${injection.label}`
const open = expanded === key
return (
<div key={key} className="rounded-[var(--radius-md)] border border-[var(--color-border)]">
<button
type="button"
onClick={() => setExpanded((current) => (current === key ? null : key))}
aria-expanded={open}
className="flex w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container)]"
>
<span className="shrink-0 rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{t(CONTEXT_KIND_LABEL[injection.kind])}
</span>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[var(--color-text-primary)]">
{injection.label}
</span>
<span className="shrink-0 font-mono text-[11px] text-[var(--color-text-tertiary)]">
{t('trace.detail.chars', { count: injection.text.length })}
</span>
</button>
{open ? (
<pre className="max-h-[320px] overflow-y-auto whitespace-pre-wrap break-words border-t border-[var(--color-border)] px-2.5 py-2 text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{injection.text}
</pre>
) : null}
</div>
)
})}
</div>
)
}
function ToolDefinitions({ tools }: { tools: Array<{ name: string; description?: string; schema?: unknown }> }) {
const [expanded, setExpanded] = useState<string | null>(null)
const active = tools.find((tool) => tool.name === expanded)
@@ -1,8 +1,104 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from '../../../i18n'
import type { TraceSpan, TraceViewModel } from '../../../lib/traceViewModel'
import { formatDurationMs, formatTokenCount } from '../../../lib/trace/formatters'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody } from '../../../lib/trace/requestParse'
import { StatusGlyph, TypeIcon, spanDisplayTitle } from '../TraceBadges'
import { Section } from './Section'
/**
* The model-visible request header as the session opened, read from the first
* model call so it does not have to be hunted down inside an individual one.
* Only that call is fetched, because session-level snapshots truncate request
* bodies.
*
* It is the opening header, not a session-wide invariant: late tool
* registration and a mid-session model change both rewrite the system prompt
* and tool catalog for later requests. A specific request's own header stays on
* that request's detail.
*/
function SessionRequestHeader({
viewModel,
sessionId,
revisionKey,
}: {
viewModel: TraceViewModel
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const [header, setHeader] = useState<{
system?: string
tools: Array<{ name: string; description?: string }>
} | null>(null)
const firstCallId = useMemo(() => {
for (const candidate of viewModel.spans) {
if (candidate.kind === 'llm' && candidate.call?.id) return candidate.call.id
}
return null
}, [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
let cancelled = false
void fetchTraceCallDetail(sessionId, firstCallId, revisionKey).then((call) => {
if (cancelled) return
const preview = call?.request.body.preview
if (!preview || !call) return
const parsed = parseTraceRequestBody(preview, call.source)
if (!parsed) return
setHeader({
...(parsed.system !== undefined ? { system: parsed.system } : {}),
tools: parsed.tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
})),
})
})
return () => { cancelled = true }
}, [sessionId, firstCallId, revisionKey])
if (!header) return null
const hasSystem = Boolean(header.system)
if (!hasSystem && header.tools.length === 0) return null
return (
<div className="mt-5 flex flex-col gap-1">
{header.system ? (
<Section
sectionKey="overview.systemPrompt"
title={t('trace.section.systemPrompt')}
badge={t('trace.detail.chars', { count: header.system.length })}
>
<pre className="max-h-[360px] overflow-y-auto whitespace-pre-wrap break-words text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{header.system}
</pre>
</Section>
) : null}
{header.tools.length > 0 ? (
<Section sectionKey="overview.tools" title={t('trace.section.tools')} badge={header.tools.length}>
<div className="flex flex-col gap-1">
{header.tools.map((tool) => (
<div key={tool.name} className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-primary)]">{tool.name}</span>
{tool.description ? (
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]">
{tool.description}
</span>
) : null}
</div>
))}
</div>
</Section>
) : null}
</div>
)
}
type OverviewStats = {
llmCalls: number
@@ -20,10 +116,14 @@ export function SessionOverview({
span,
viewModel,
onSelect,
sessionId,
revisionKey,
}: {
span: TraceSpan
viewModel: TraceViewModel
onSelect: (spanId: string) => void
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const stats = useMemo(() => computeStats(span, viewModel), [span, viewModel])
@@ -47,6 +147,8 @@ export function SessionOverview({
<Stat label={t('trace.models')} value={stats.models.length > 0 ? stats.models.join(', ') : '--'} />
</div>
<SessionRequestHeader viewModel={viewModel} sessionId={sessionId} revisionKey={revisionKey} />
{children.length > 0 ? (
<div className="mt-6">
<div className="mb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">