mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
Reference in New Issue
Block a user