Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui

# Conflicts:
#	packages/client/ui-primitives/src/Menu.tsx
#	packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
#	packages/client/ui-trajectory/src/client/TrajectoryView.tsx
#	packages/client/ui-trajectory/tests/layout.spec.tsx
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
This commit is contained in:
_Kerman
2026-07-28 10:12:29 +08:00
1303 changed files with 41080 additions and 19714 deletions

View File

@@ -19,18 +19,20 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'System',
user: 'User',
context: 'Context',
compacted: 'Compacted',
message: 'Message',
tool: 'Tool',
subtool: 'Sub',
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
system: css.tagSystem!,
user: css.tagUser!,
context: css.tagContext!,
message: css.tagMessage!,
tool: css.tagTool!,
subtool: css.tagSubtool!,
const TAG_CLASS: Record<TrajectoryCellKind, string | undefined> = {
system: css.tagSystem,
user: css.tagUser,
context: css.tagContext,
compacted: css.tagSystem,
message: css.tagMessage,
tool: css.tagTool,
subtool: css.tagSubtool,
}
/**
@@ -73,7 +75,7 @@ export function TrajectoryCell({
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
<span className={css.index}>#{index}</span>
<span className={css.tagSlot}>
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
<span className={[css.tag, TAG_CLASS[kind]].filter((c): c is string => c !== undefined).join(' ')}>{KIND_LABEL[kind]}</span>
</span>
<span className={css.text}>{text}</span>
<span className={css.trailing}>

View File

@@ -14,7 +14,7 @@ import css from './TrajectoryStatsHeader.module.css'
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
const nodes = useSession((s) => s.nodes)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>

View File

@@ -32,7 +32,7 @@
}
.eventColumn {
width: 104px;
width: 122px;
}
.contentColumn {
@@ -111,7 +111,7 @@
position: absolute;
z-index: 6;
top: -8px;
left: 2px;
left: 12px;
width: 16px;
height: 16px;
padding: 0;
@@ -242,7 +242,7 @@
.event {
overflow: visible !important;
padding-right: 4px !important;
padding-left: 18px !important;
padding-left: 36px !important;
}
.turnLabel {
@@ -835,16 +835,6 @@
outline-offset: 2px;
}
.tokenEquation {
display: inline-flex;
align-items: baseline;
}
.tokenOperator {
margin: 0 2px;
color: var(--dsw-alias-label-caption);
}
.overviewSections {
display: flex;
flex: 1;

View File

@@ -220,11 +220,11 @@ export interface TrajectoryTableProps {
/** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
onToggleTurn(turn: number): void
onToggleTurn: (turn: number) => void
/** Assistant record indexes whose tool calls are folded. */
collapsedAssistants: ReadonlySet<number>
/** Toggle tool calls under one assistant record. */
onToggleAssistant(index: number): void
onToggleAssistant: (index: number) => void
}
/** One request identity paired with its session-global number. */
@@ -442,20 +442,29 @@ function statusLabel(state: RecordState): string {
return 'Completed'
}
function tokenSummary(cell: TrajectoryCellProps): ReactNode {
if (cell.kind !== 'message') return '—'
if (cell.output === undefined) return '—'
if (cell.think === undefined) return String(cell.output)
function TokenRows({ cell }: { cell: TrajectoryCellProps }) {
const content = cell.output !== undefined && cell.think !== undefined
? Math.max(0, cell.output - cell.think)
: undefined
return (
<span className={css.tokenEquation}>
<span title="Total output tokens">{cell.output}</span>
<span className={css.tokenOperator}>=</span>
<span title="Non-reasoning output tokens">
{Math.max(0, cell.output - cell.think)}
</span>
<span className={css.tokenOperator}>+</span>
<span title="Reasoning tokens">{cell.think}</span>
</span>
<>
<div>
<dt>Tokens</dt>
<dd>{cell.output === undefined ? '—' : `${cell.output} tok`}</dd>
</div>
{cell.think !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Reasoning</dt>
<dd>{cell.think} tok</dd>
</div>
)}
{content !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Content</dt>
<dd>{content} tok</dd>
</div>
)}
</>
)
}
@@ -551,7 +560,7 @@ function RequestOptions({
<JsonTree
data={options}
label="Request options JSON"
className={preview ? css.jsonPreview! : css.jsonPayload!}
className={preview ? css.jsonPreview : css.jsonPayload}
/>
)
}
@@ -560,16 +569,17 @@ function messageOriginLabel(source: unknown): string {
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
return 'Unknown'
}
const kind = Reflect.get(source, 'kind')
const properties = source as Record<string, unknown>
const kind = properties.kind
if (kind === 'user') return 'User'
if (kind === 'plugin') {
const plugin = Reflect.get(source, 'plugin')
const plugin = properties.plugin
return typeof plugin === 'string' && plugin !== ''
? `Plugin · ${plugin}`
: 'Plugin'
}
if (kind === 'goal') {
const round = Reflect.get(source, 'round')
const round = properties.round
return typeof round === 'number' && round > 0
? `Goal · Round ${round}`
: 'Goal'
@@ -591,7 +601,7 @@ function MessageOrigin({ record }: { record: TableRecord }) {
<JsonTree
data={data}
label="Message origin JSON"
className={css.jsonPayload!}
className={css.jsonPayload}
/>
)
}
@@ -736,7 +746,7 @@ function SourceBlocks({
onOpenCall,
}: {
blocks: readonly TrajectorySourceBlock[]
onOpenCall(callId: string): void
onOpenCall: (callId: string) => void
}) {
return (
<div className={css.sourceBlocks}>
@@ -744,28 +754,28 @@ function SourceBlocks({
<section className={css.sourceBlock} key={index}>
{block.callId !== undefined
? (
<button
type="button"
className={css.sourceBlockJumpTarget}
aria-label={`Open Block #${index + 1} tool call summary`}
title="Open tool call summary"
onClick={() => {
if (block.callId !== undefined) onOpenCall(block.callId)
}}
>
<span className={css.sourceBlockLabel}>
{`Block #${index + 1} ${block.type}`}
</span>
<IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
</button>
)
<button
type="button"
className={css.sourceBlockJumpTarget}
aria-label={`Open Block #${index + 1} tool call summary`}
title="Open tool call summary"
onClick={() => {
if (block.callId !== undefined) onOpenCall(block.callId)
}}
>
<span className={css.sourceBlockLabel}>
{`Block #${index + 1} ${block.type}`}
</span>
<IconChevronRightOutline14 className={css.sourceBlockJumpIcon} size={12} />
</button>
)
: (
<div className={css.sourceBlockHeader}>
<span className={css.sourceBlockLabel}>
{`Block #${index + 1} ${block.type}`}
</span>
</div>
)}
<div className={css.sourceBlockHeader}>
<span className={css.sourceBlockLabel}>
{`Block #${index + 1} ${block.type}`}
</span>
</div>
)}
{block.imageSrc !== undefined
? <PanelImage block={block} />
: <pre className={css.sourceBlockContent}>{block.content}</pre>}
@@ -823,7 +833,7 @@ function AssistantToolCalls({
}: {
blocks: readonly TrajectorySourceBlock[] | undefined
preview: boolean
onOpenCall(callId: string): void
onOpenCall: (callId: string) => void
}) {
const calls = blocks?.filter(block => block.type === 'tool-call') ?? []
if (calls.length === 0) return null
@@ -1031,8 +1041,8 @@ function MarkdownRecordContent({
rendered: boolean
preview?: boolean
thinkingExpanded: boolean
onThinkingExpandedChange(expanded: boolean): void
onOpenCall(callId: string): void
onThinkingExpandedChange: (expanded: boolean) => void
onOpenCall: (callId: string) => void
}) {
if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
@@ -1046,10 +1056,7 @@ function MarkdownRecordContent({
return <MarkdownFragment text={source} rendered={false} preview={preview} />
}
return (
<div className={rendered
? `${css.assistantContent} ${css.assistantContentRendered}`
: css.assistantContent}
>
<div className={`${css.assistantContent} ${css.assistantContentRendered}`}>
<div className={
preview && !record.cell.outputDetail
? `${css.thinkingQuote} ${css.thinkingQuoteOnlyPreview}`
@@ -1125,12 +1132,12 @@ function RecordTiming({ record }: { record: TableRecord }) {
return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined
? <AssistantTimingPanel metrics={record.cell.assistantMetrics} />
: (
<dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
<div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div>
</dl>
)
<dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={record.cell.startedAt ?? null} /></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(record.cell.timeSeconds)}</dd></div>
<div><dt>Timing source</dt><dd>{record.cell.timeSeconds === null ? 'Not available' : 'Session timestamps'}</dd></div>
</dl>
)
}
function RequestTiming({
@@ -1216,7 +1223,7 @@ function RecordPayload({
<JsonTree
data={json}
label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
className={preview ? css.jsonPreview! : css.jsonPayload!}
className={preview ? css.jsonPreview : css.jsonPayload}
/>
)
}
@@ -1312,7 +1319,7 @@ function OverviewSection({
children,
}: {
label: string
onOpen(): void
onOpen: () => void
children: ReactNode
}) {
return (
@@ -1369,9 +1376,9 @@ export function TrajectoryTable({
const selectedRequestRecords = selectedRequest === null
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
record.turn === selectedRequest.turn
&& record.group === selectedRequest.group,
)
)
const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message',
)
@@ -1401,22 +1408,22 @@ export function TrajectoryTable({
selectedRequestAssistant === undefined
? undefined
: {
...(selectedRequestAssistant.cell.input === undefined
? {}
: { input: selectedRequestAssistant.cell.input }),
...(selectedRequestAssistant.cell.cacheRead === undefined
? {}
: { cacheRead: selectedRequestAssistant.cell.cacheRead }),
...(selectedRequestAssistant.cell.cacheWrite === undefined
? {}
: { cacheWrite: selectedRequestAssistant.cell.cacheWrite }),
...(selectedRequestAssistant.cell.output === undefined
? {}
: { output: selectedRequestAssistant.cell.output }),
...(selectedRequestAssistant.cell.think === undefined
? {}
: { reasoning: selectedRequestAssistant.cell.think }),
}
...(selectedRequestAssistant.cell.input === undefined
? {}
: { input: selectedRequestAssistant.cell.input }),
...(selectedRequestAssistant.cell.cacheRead === undefined
? {}
: { cacheRead: selectedRequestAssistant.cell.cacheRead }),
...(selectedRequestAssistant.cell.cacheWrite === undefined
? {}
: { cacheWrite: selectedRequestAssistant.cell.cacheWrite }),
...(selectedRequestAssistant.cell.output === undefined
? {}
: { output: selectedRequestAssistant.cell.output }),
...(selectedRequestAssistant.cell.think === undefined
? {}
: { reasoning: selectedRequestAssistant.cell.think }),
}
)
const selectedRequestCumulativeUsage =
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
@@ -1428,16 +1435,18 @@ export function TrajectoryTable({
const selectedParents: ParentRecords = selected === undefined
? {}
: parentRecords(allRecords, selected)
const selectedParentMessage = selectedParents.message
const selectedParentTool = selectedParents.tool
const selectedAssistantRequest = selected?.cell.kind === 'message'
? requestNumbers.get(requestKey(selected.turn, selected.group))
: undefined
const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
number: selectedAssistantRequest,
group: selected.group,
}
turn: selected.turn,
number: selectedAssistantRequest,
group: selected.group,
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
|| selectedParents.message !== undefined
@@ -1445,8 +1454,8 @@ export function TrajectoryTable({
const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
? undefined
: {
'--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`,
}
'--trajectory-tool-request-width': `calc(58cqw - ${toolRequestOffset}px)`,
}
const activateTab = (tab: DetailTab) => {
tabHistory.current.delete(tab)
@@ -1554,11 +1563,11 @@ export function TrajectoryTable({
onClick={isRequestOnly
? undefined
: isCollapsedSummary
? () => {
? () => {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
}
: () => { selectRecord(record.cell.index) }}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (collapsedTurns.has(record.turn)) {
@@ -1594,94 +1603,94 @@ export function TrajectoryTable({
selectRecord(record.cell.index)
}}
>
<td className={css.event}>
{request !== undefined && (
<button
type="button"
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
onClick={(event) => {
event.stopPropagation()
selectRequest({
turn: record.turn,
number: request,
group: record.group,
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
<td className={css.event}>
{request !== undefined && (
<button
type="button"
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
onClick={(event) => {
event.stopPropagation()
selectRequest({
turn: record.turn,
number: request,
group: record.group,
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
&& !isRequestOnly
&& record.turnStart && (
<span
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
</span>
)}
<div className={css.eventInner}>
{!isCollapsedSummary && !isRequestOnly && (
<span
className={css.kindSlot}
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
Turn {record.turn}
</span>
)}
</div>
</td>
<td className={css.content}>
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}>…</span>
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
</span>
)
: (
<div className={css.eventInner}>
{!isCollapsedSummary && !isRequestOnly && (
<span
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined
? listDisplayText
: `${listDisplayText} → ${record.cell.result}`}
className={css.kindSlot}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
: toolCallText === undefined
? listDisplayText || '—'
: (
<span className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
)}
</div>
</td>
<td className={css.content}>
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}>…</span>
<span className={css.collapsedTurnText}>{record.collapsedSummary}</span>
</span>
)
: (
<span
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined
? listDisplayText
: `${listDisplayText} → ${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
: toolCallText === undefined
? listDisplayText || '—'
: (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
@@ -1693,21 +1702,21 @@ export function TrajectoryTable({
)}
</>
)}
</span>
{record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}>→</span>
<span className={record.cell.result === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
</span>
</span>
)}
</span>
)}
</td>
{record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}>→</span>
<span className={record.cell.result === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
</span>
</span>
)}
</span>
)}
</td>
</tr>
)
})}
@@ -1738,7 +1747,7 @@ export function TrajectoryTable({
if (event.button !== 0) return
const details = event.currentTarget.parentElement
const split = details?.parentElement
if (details === null || details === undefined || split === null || split === undefined) return
if (details === null || split === null) return
const splitWidth = split.getBoundingClientRect().width
detailsResizeDrag.current = {
pointerId: event.pointerId,
@@ -1777,7 +1786,7 @@ export function TrajectoryTable({
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
const details = event.currentTarget.parentElement
const split = details?.parentElement
if (details === null || details === undefined || split === null || split === undefined) return
if (details === null || split === null) return
const direction = event.key === 'ArrowLeft' ? 1 : -1
const currentDetailsWidth = details.getBoundingClientRect().width
const splitWidth = split.getBoundingClientRect().width
@@ -1800,40 +1809,40 @@ export function TrajectoryTable({
<div className={css.detailsTitle}>
{selectedRequest !== null
? (
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
</span>
</>
)
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
</span>
</>
)
: promptSelected
? (
? (
<>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<span className={css.detailsLocation}>{selected?.cell.text}</span>
</>
)
: selected !== undefined && (
: selected !== undefined && (
<>
<span className={`${css.kindTag} ${
selected.cell.kind === 'context'
? css.contextGreen
: selected.cell.kind === 'compacted'
? css.compacted
: selected.cell.kind === 'tool'
? css.toolAmber
: selected.cell.kind === 'message'
? css.assistantVioletBright
: selected.cell.kind === 'subtool'
? css.subtoolAmber
: css[selected.cell.kind]
}`}
: selected.cell.kind === 'tool'
? css.toolAmber
: selected.cell.kind === 'message'
? css.assistantVioletBright
: selected.cell.kind === 'subtool'
? css.subtoolAmber
: css[selected.cell.kind]
}`}
>
{KIND_LABEL[selected.cell.kind]}
</span>
@@ -2019,12 +2028,12 @@ export function TrajectoryTable({
)}
{promptSelected && activeTab === 'system-prompt' && (
selectedPrompt.system === ''
? <p className={css.noPayload}>No system prompt in this request</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={selectedPrompt.system} />
</div>
)
? <p className={css.noPayload}>No system prompt in this request</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={selectedPrompt.system} />
</div>
)
)}
{promptSelected && activeTab === 'tools' && (
<ToolCatalog tools={selectedPrompt.tools} />
@@ -2045,7 +2054,7 @@ export function TrajectoryTable({
</div>
<div>
<dt>Tokens</dt>
<dd>{tokenSummary(selected.cell)}</dd>
<dd>—</dd>
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
@@ -2109,11 +2118,11 @@ export function TrajectoryTable({
/>
</button>
)}
{selectedParents.message !== undefined && (
{selectedParentMessage !== undefined && (
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.message!) }}
onClick={() => { openRecordSummary(selectedParentMessage) }}
>
<span>Assistant Message</span>
<IconChevronRightOutline14
@@ -2122,11 +2131,11 @@ export function TrajectoryTable({
/>
</button>
)}
{selectedParents.tool !== undefined && (
{selectedParentTool !== undefined && (
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.tool!) }}
onClick={() => { openRecordSummary(selectedParentTool) }}
>
<span>Tool Call</span>
<IconChevronRightOutline14
@@ -2143,7 +2152,7 @@ export function TrajectoryTable({
<dd>{statusLabel(selectedState)}</dd>
</div>
{selected.cell.kind === 'message' && (
<div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div>
<TokenRows cell={selected.cell} />
)}
{(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
<div>
@@ -2155,36 +2164,36 @@ export function TrajectoryTable({
<div className={css.overviewSections}>
{isMarkdownRecord(selected)
? (
<>
<OverviewSection label="Preview" onOpen={() => { activateTab('rendered') }}>
<MarkdownRecordContent
record={selected}
rendered
preview
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
</OverviewSection>
</>
)
<>
<OverviewSection label="Preview" onOpen={() => { activateTab('rendered') }}>
<MarkdownRecordContent
record={selected}
rendered
preview
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
</OverviewSection>
</>
)
: (
<>
{selected.cell.inputDetail && (
<OverviewSection label="Payload" onOpen={() => { activateTab('input') }}>
<RecordPayload record={selected} direction="input" preview />
</OverviewSection>
)}
{selected.cell.outputDetail && (
<OverviewSection label="Result" onOpen={() => { activateTab('output') }}>
<RecordPayload record={selected} direction="output" preview />
</OverviewSection>
)}
<OverviewSection label="Schema" onOpen={() => { activateTab('schema') }}>
<RecordSchema record={selected} preview />
<>
{selected.cell.inputDetail && (
<OverviewSection label="Payload" onOpen={() => { activateTab('input') }}>
<RecordPayload record={selected} direction="input" preview />
</OverviewSection>
</>
)}
)}
{selected.cell.outputDetail && (
<OverviewSection label="Result" onOpen={() => { activateTab('output') }}>
<RecordPayload record={selected} direction="output" preview />
</OverviewSection>
)}
<OverviewSection label="Schema" onOpen={() => { activateTab('schema') }}>
<RecordSchema record={selected} preview />
</OverviewSection>
</>
)}
{selectedAssistantRequestTarget !== undefined && (
<OverviewSection
label="Timing"

View File

@@ -8,13 +8,13 @@ export interface TrajectoryToolbarProps {
/** Whether every collapsible turn is currently folded. */
allTurnsCollapsed: boolean
/** Fold or expand every collapsible turn. */
onToggleAllTurns(): void
onToggleAllTurns: () => void
/** Number of assistant messages followed by tool calls. */
collapsibleAssistants: number
/** Whether every collapsible assistant's tool calls are currently folded. */
allAssistantsCollapsed: boolean
/** Fold or expand tool calls under every collapsible assistant. */
onToggleAllAssistants(): void
onToggleAllAssistants: () => void
}
/**

View File

@@ -20,7 +20,7 @@ export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
<div className={css.inner}>
<span className={css.title}>Turn {turn}</span>
<div className={css.columns} aria-hidden="true">
{COLUMN_LABELS.map((label) => (
{COLUMN_LABELS.map(label => (
<span key={label} className={css.column}>{label}</span>
))}
</div>

View File

@@ -71,21 +71,21 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const nodes = useSession((s) => s.nodes)
const projectedContexts = useSession((s) => s.contexts)
const nodes = useSession(s => s.nodes)
const projectedContexts = useSession(s => s.contexts)
const compactionRequests = useSession(
(s) => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
s => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
)
const requestAttempts = useSession(
(s) => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
s => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
)
const promptChanges = useSession(
(s) => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
s => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const callSchemas = useSession((s) => s.callSchemas)
const codeDispatches = useSession((s) => s.codeDispatches)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const callSchemas = useSession(s => s.callSchemas)
const codeDispatches = useSession(s => s.codeDispatches)
const contexts = useMemo<readonly ConversationContext[]>(
() => projectedContexts === undefined || projectedContexts.length === 0
? [{ id: 0, nodes }]
@@ -98,7 +98,11 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = currentBranch.nodes
const selectedNodes = useMemo(() => {
const bySeq = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of nodes) bySeq.set(node.seq, node)
return [...bySeq.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, nodes])
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
@@ -128,11 +132,11 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
attemptsByStep.has(key)
? []
: [{
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
),
...compactionRequests.map(request => ({
seq: request.startSeq,

View File

@@ -24,8 +24,8 @@ export interface WaterfallExtraProps {
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession((s) => s.nodes)
const codeDispatches = useSession((s) => s.codeDispatches)
const nodes = useSession(s => s.nodes)
const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
@@ -50,7 +50,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map((lane) => (
{(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span

View File

@@ -219,7 +219,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
if (entry.kind === 'system') {
const { change } = entry
const turn = enclosingPromptTurn(nodes, change.seq, partial)
const turn = change.kind === 'initial'
? firstVisibleTurn(nodes, partial)
: enclosingPromptTurn(nodes, change.seq, partial)
pushMessage(turn, {
absTime: finiteTime(change.time),
cell: {
@@ -716,6 +718,20 @@ function enclosingPromptTurn(
return partial?.turn ?? 1
}
/** Earliest raw turn represented by the selected trajectory branch. */
function firstVisibleTurn(
nodes: ConversationSnapshot['nodes'],
partial: ConversationSnapshot['partial'],
): number {
const turns = nodes.flatMap(node =>
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
? [node.turn]
: [],
)
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
return turns.length === 0 ? 1 : Math.min(...turns)
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return

View File

@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))

View File

@@ -73,13 +73,13 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
expect(kinds).toEqual(['user', 'message', 'tool'])
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
input: 10, output: 20, think: 5, timeSeconds: 5,
})
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
expect(tool?.text).toBe('bash · {"command":"ls"}')
expect(tool?.timeSeconds).toBe(1.3)
})
@@ -87,14 +87,14 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [] as unknown as ConversationSnapshot['nodes'],
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
}],
})
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
})
@@ -113,9 +113,9 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
})
it('builds a wall-span step description with a tool histogram', () => {
@@ -156,9 +156,9 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns.map((t) => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage and a meaningful summary when assistant has no text block', () => {
@@ -170,7 +170,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '仅推理输出', input: 11, output: 22, think: 3,
})
@@ -199,8 +199,8 @@ describe('deriveTrajectoryLayout', () => {
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap((g) => g.cells)
.find((c) => c.kind === 'message' && c.text === 'done')
.flatMap(g => g.cells)
.find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
@@ -234,11 +234,11 @@ describe('run_code sub-dispatch cells', () => {
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap((g) => g.cells)
expect(cells.map((c) => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('请求调用 run_code')
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map((c) => c.index)).toEqual([1, 2, 3, 4])
expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
})
@@ -250,7 +250,7 @@ describe('run_code sub-dispatch cells', () => {
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap((g) => g.cells).find((c) => c.kind === 'subtool')
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
})

View File

@@ -53,6 +53,13 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
}],
}]
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} collapsed={false} />)
@@ -64,6 +71,18 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
expect(screen.getByText('Tokens')).toBeTruthy()
expect(screen.getByText('20 tok')).toBeTruthy()
expect(screen.getByText('Reasoning')).toBeTruthy()
expect(screen.getByText('5 tok')).toBeTruthy()
expect(screen.getByText('Content')).toBeTruthy()
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} collapsed={false} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()

View File

@@ -144,7 +144,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
version: () => slots.getVersion('conversation.view'),
}}
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() } as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
@@ -164,7 +164,7 @@ describe('plugin registration', () => {
it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
})
})
@@ -173,7 +173,7 @@ describe('tab switching in ConversationRoot', () => {
const b = await bench()
const view = mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -224,7 +224,7 @@ describe('tab switching in ConversationRoot', () => {
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
const b = await bench()
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
mount(b.slots, [])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -247,12 +247,12 @@ describe('span derivation', () => {
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
@@ -260,7 +260,7 @@ describe('span derivation', () => {
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
@@ -274,7 +274,7 @@ describe('WaterfallView standalone branches', () => {
describe('node half', () => {
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
expect(nodeApply()).toBeUndefined()
expect(() => { nodeApply() }).not.toThrow()
})
})
@@ -321,7 +321,7 @@ describe('deriveSubSpans (waterfall lanes)', () => {
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find((lane) => lane.name === 'grep')
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)