feat(ui): refine trajectory inspection details

This commit is contained in:
_Kerman
2026-07-27 17:10:19 +08:00
parent 714090bb4d
commit 0457d76bb6
9 changed files with 526 additions and 91 deletions

View File

@@ -29,7 +29,8 @@ export type {
} from './contract/store.ts' } from './contract/store.ts'
export type { export type {
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
export { PendingWait } from './sessions/pending.ts' export { PendingWait } from './sessions/pending.ts'

View File

@@ -209,6 +209,14 @@ export type ComposerPhase = 'blank' | 'engaging' | 'active'
/** Operation that started a new append-only model context. */ /** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** Latest complete model request header in force within one context generation. */
export interface ConversationPromptSnapshot {
/** Rendered system prompt text; empty when the request had no system prompt. */
system: string
/** Complete tool catalog sent with the request, including tools that were never called. */
tools: readonly ToolSchema[]
}
/** One immutable model-context generation reconstructed from surface replacements. */ /** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext { export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */ /** Zero-based generation within the session; stable across later appends. */
@@ -221,6 +229,8 @@ export interface ConversationContext {
originSeq?: number originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */ /** Unix epoch ms of the replacement that created this generation. */
createdAt?: number createdAt?: number
/** Latest request header observed in this generation, inherited until a later header replaces it. */
prompt?: ConversationPromptSnapshot
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */ /** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[] nodes: readonly ConversationNode[]
} }

View File

@@ -13,6 +13,7 @@ import {
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { import type {
AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
ConversationPromptSnapshot,
} from './conversation.ts' } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts'
@@ -111,9 +112,12 @@ export class FoldAdapter {
* reference-stability contract (§A.9.4) starts here. */ * reference-stability contract (§A.9.4) starts here. */
private rev = 0 private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** Revision of the model-visible surface only; log-only chunks do not rebuild context generations. */ /** Revision of context structure or its request header; unrelated log-only events do not rebuild contexts. */
private surfaceRev = 0 private contextRev = 0
private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null private contextsResult: { rev: number; value: readonly ConversationContext[] } | null = null
private contextGeneration = 0
private activePrompt: ConversationPromptSnapshot | undefined
private promptsByContext = new Map<number, ConversationPromptSnapshot>()
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> { get callIndex(): ReadonlyMap<string, CallIndexEntry> {
@@ -129,7 +133,7 @@ export class FoldAdapter {
*/ */
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void { reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++ this.rev++
this.surfaceRev++ this.contextRev++
this.baseSeq = baseSeq this.baseSeq = baseSeq
this.padded = [] this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i)) for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
@@ -139,10 +143,16 @@ export class FoldAdapter {
this.degraded = false this.degraded = false
this.callIdx = new Map() this.callIdx = new Map()
this.resultViews.clear() this.resultViews.clear()
this.contextGeneration = 0
this.activePrompt = undefined
this.promptsByContext = new Map()
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
const event = events[i] const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.indexCall(event, views?.[i]) if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexContextPrompt(event)
}
} }
} }
@@ -154,9 +164,10 @@ export class FoldAdapter {
*/ */
append(event: SessionEvent, view?: ToolEventView): void { append(event: SessionEvent, view?: ToolEventView): void {
this.rev++ this.rev++
if (isSurfaceEvent(event)) this.surfaceRev++ if (isSurfaceEvent(event) || event.type === 'request/header') this.contextRev++
this.padded.push(event) this.padded.push(event)
this.indexCall(event, view) this.indexCall(event, view)
this.indexContextPrompt(event)
} }
/** /**
@@ -207,13 +218,17 @@ export class FoldAdapter {
* @returns Frozen historical contexts followed by the current context. * @returns Frozen historical contexts followed by the current context.
*/ */
contexts(): readonly ConversationContext[] { contexts(): readonly ConversationContext[] {
if (this.contextsResult !== null && this.contextsResult.rev === this.surfaceRev) { if (this.contextsResult !== null && this.contextsResult.rev === this.contextRev) {
return this.contextsResult.value return this.contextsResult.value
} }
const current = this.nodes() const current = this.nodes()
if (current.degraded) { if (current.degraded) {
const value: readonly ConversationContext[] = [{ id: 0, nodes: current.nodes }] const value: readonly ConversationContext[] = [{
this.contextsResult = { rev: this.surfaceRev, value } id: 0,
...(this.activePrompt === undefined ? {} : { prompt: this.activePrompt }),
nodes: current.nodes,
}]
this.contextsResult = { rev: this.contextRev, value }
return value return value
} }
const value = this.surface.contexts.map((context): ConversationContext => { const value = this.surface.contexts.map((context): ConversationContext => {
@@ -222,7 +237,14 @@ export class FoldAdapter {
const node = this.materialize(seq) const node = this.materialize(seq)
if (node !== undefined) nodes.push(node) if (node !== undefined) nodes.push(node)
} }
if (context.origin === undefined) return { id: context.generation, nodes } const prompt = this.promptsByContext.get(context.generation)
if (context.origin === undefined) {
return {
id: context.generation,
...(prompt === undefined ? {} : { prompt }),
nodes,
}
}
const originEvent = this.padded[context.origin.seq] const originEvent = this.padded[context.origin.seq]
return { return {
id: context.generation, id: context.generation,
@@ -230,10 +252,11 @@ export class FoldAdapter {
origin: contextOriginKind(originEvent), origin: contextOriginKind(originEvent),
originSeq: context.origin.seq, originSeq: context.origin.seq,
...(originEvent === undefined ? {} : { createdAt: originEvent.time }), ...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
...(prompt === undefined ? {} : { prompt }),
nodes, nodes,
} }
}) })
this.contextsResult = { rev: this.surfaceRev, value } this.contextsResult = { rev: this.contextRev, value }
return value return value
} }
@@ -303,6 +326,21 @@ export class FoldAdapter {
// No backfill into already-materialized tool-result nodes for this callId // No backfill into already-materialized tool-result nodes for this callId
// (window order puts the call before its result; cannot happen on the normal path). // (window order puts the call before its result; cannot happen on the normal path).
} }
private indexContextPrompt(event: SessionEvent): void {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
this.contextGeneration++
if (this.activePrompt !== undefined) {
this.promptsByContext.set(this.contextGeneration, this.activePrompt)
}
}
if (event.type !== 'request/header') return
this.activePrompt = {
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
this.promptsByContext.set(this.contextGeneration, this.activePrompt)
}
} }
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
@@ -310,10 +348,8 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext
const source = event.data.source const source = event.data.source
if ( if (
typeof source === 'object' typeof source === 'object'
&& source !== null
&& 'kind' in source && 'kind' in source
&& 'plugin' in source && 'plugin' in source
&& source.kind === 'plugin'
) { ) {
if (source.plugin === 'compact') return 'compaction' if (source.plugin === 'compact') return 'compaction'
if (source.plugin === 'rewind') return 'rewind' if (source.plugin === 'rewind') return 'rewind'

View File

@@ -13,7 +13,8 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
font: 12px/16px var(--ds-font-family-code); font: 12px/16px var(--ds-font-family-code);
overscroll-behavior: contain; overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
} }
:global(body[data-ds-dark-theme]) .root { :global(body[data-ds-dark-theme]) .root {
@@ -78,8 +79,8 @@
position: absolute; position: absolute;
z-index: 0; z-index: 0;
top: 0; top: 0;
right: -100vw; right: 0;
left: -100vw; left: 0;
height: 16px; height: 16px;
background: var(--json-tree-hover); background: var(--json-tree-hover);
content: ''; content: '';
@@ -134,7 +135,7 @@
} }
.copyAnchor { .copyAnchor {
position: absolute; position: fixed;
z-index: 3; z-index: 3;
display: inline-flex; display: inline-flex;
} }

View File

@@ -235,14 +235,29 @@ export function JsonTree({
const rootRect = root.getBoundingClientRect() const rootRect = root.getBoundingClientRect()
const rowRect = row.getBoundingClientRect() const rowRect = row.getBoundingClientRect()
setCopyTarget({ setCopyTarget({
left: root.scrollLeft + root.clientWidth - 26, left: rootRect.left + root.clientWidth - 26,
path: target.path, path: target.path,
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
top: root.scrollTop + rowRect.top - rootRect.top, top: rowRect.top,
value: target.value, value: target.value,
}) })
} }
useEffect(() => {
const reposition = () => {
const row = activeRowRef.current
if (row === undefined) return
const resolved = resolveRow(data, row, expandTopLevel)
if (resolved !== undefined) positionCopyButton(row, resolved)
}
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [data, expandTopLevel])
const clearCopyTarget = () => { const clearCopyTarget = () => {
setActiveRow(undefined) setActiveRow(undefined)
setCopyTarget(undefined) setCopyTarget(undefined)

View File

@@ -52,13 +52,29 @@
} }
.tagContext { .tagContext {
color: var(--dsw-alias-label-secondary); color: color-mix(
background: var(--dsw-alias-bg-layer-3); in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
background: var(--dsw-alias-state-success-tertiary);
} }
.tagMessage { .tagMessage {
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); color: color-mix(
background: var(--dsw-specific-bubble); in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
background: color-mix(
in srgb,
color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
var(--dsw-alias-state-error-secondary)
) 15%,
var(--dsw-alias-bg-layer-1)
);
} }
.tagTool { .tagTool {
@@ -69,8 +85,16 @@
/* run_code sub-dispatch cells: the business tint plus an indent so the /* run_code sub-dispatch cells: the business tint plus an indent so the
nesting under the parent Tool cell reads at a glance. */ nesting under the parent Tool cell reads at a glance. */
.tagSubtool { .tagSubtool {
color: var(--dsw-alias-state-business-primary); color: color-mix(
background: var(--dsw-alias-state-business-tertiary); in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-tertiary) 58%,
var(--dsw-alias-bg-layer-1)
);
} }
.root[data-kind='subtool'] { .root[data-kind='subtool'] {

View File

@@ -79,7 +79,7 @@
transition: background-color 120ms var(--ds-ease-in-out); transition: background-color 120ms var(--ds-ease-in-out);
} }
.table tbody tr:not([data-collapsed-summary]):hover { .table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
@@ -230,28 +230,53 @@
background: var(--dsw-alias-state-business-tertiary); background: var(--dsw-alias-state-business-tertiary);
} }
.context { .systemNeutral {
border-color: var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
}
.message {
border-color: var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform); background: var(--dsw-alias-bg-module-platform);
} }
.tool { .contextGreen {
border-color: var(--dsw-alias-border-l2); color: color-mix(
color: var(--dsw-alias-label-primary); in srgb,
background: var(--dsw-alias-bg-layer-1); var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
background: var(--dsw-alias-state-success-tertiary);
} }
.subtool { .assistantVioletBright {
border-color: var(--dsw-alias-border-l2); color: color-mix(
color: var(--dsw-alias-state-business-primary); in srgb,
background: transparent; var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
background: color-mix(
in srgb,
color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
var(--dsw-alias-state-error-secondary)
) 15%,
var(--dsw-alias-bg-layer-1)
);
}
.toolAmber {
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.subtoolAmber {
color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-tertiary) 58%,
var(--dsw-alias-bg-layer-1)
);
} }
.contentText { .contentText {
@@ -326,6 +351,18 @@
white-space: nowrap; white-space: nowrap;
} }
.toolCallNameTypeface {
color: var(--dsw-alias-label-primary);
font: 400 13px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
'Microsoft YaHei';
}
.toolCallPayload {
margin-left: 7px;
color: var(--dsw-alias-label-secondary);
font: 400 12px/18px var(--ds-font-family-code);
}
.table tbody tr[data-kind='tool'] .contentText, .table tbody tr[data-kind='tool'] .contentText,
.table tbody tr[data-kind='subtool'] .contentText, .table tbody tr[data-kind='subtool'] .contentText,
.table tbody tr[data-kind='tool'] .resultPreview, .table tbody tr[data-kind='tool'] .resultPreview,
@@ -475,7 +512,7 @@
height: 34px; height: 34px;
padding: 0 8px; padding: 0 8px;
overflow-x: auto; overflow-x: auto;
gap: 2px; gap: 1px;
border-bottom: 1px solid var(--dsw-alias-border-l2); border-bottom: 1px solid var(--dsw-alias-border-l2);
scrollbar-width: none; scrollbar-width: none;
} }
@@ -534,27 +571,32 @@
gap: 14px; gap: 14px;
} }
.overviewHierarchyLink { .overviewHierarchyNavLink {
all: unset; all: unset;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-secondary);
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
gap: 1px;
opacity: 1; opacity: 1;
text-decoration-line: underline;
text-decoration-color: currentColor;
text-decoration-style: solid;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
} }
.overviewHierarchyLink:hover { .overviewHierarchyNavLink:hover {
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
text-decoration-thickness: 2px;
} }
.overviewHierarchyLink:focus-visible { .overviewHierarchyJumpIconTight {
flex: none;
color: var(--dsw-alias-label-caption);
}
.overviewHierarchyNavLink:hover .overviewHierarchyJumpIconTight,
.overviewHierarchyNavLink:focus-visible .overviewHierarchyJumpIconTight {
color: var(--dsw-alias-label-primary);
}
.overviewHierarchyNavLink:focus-visible {
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
outline: 1px solid var(--dsw-alias-state-business-primary); outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px; outline-offset: 2px;
@@ -582,6 +624,16 @@
white-space: nowrap; white-space: nowrap;
} }
.tokenEquation {
display: inline-flex;
align-items: baseline;
}
.tokenOperator {
margin: 0 2px;
color: var(--dsw-alias-label-caption);
}
.overviewSections { .overviewSections {
border-top: 0; border-top: 0;
} }
@@ -651,8 +703,8 @@
padding-bottom: 0; padding-bottom: 0;
} }
.overviewPreview .jsonPreview > :last-child, .overviewPreview .jsonPreview > :first-child,
.overviewPreview .schemaTree > :last-child { .overviewPreview .schemaTree > :first-child {
padding-bottom: 0; padding-bottom: 0;
} }
@@ -1022,6 +1074,91 @@
white-space: nowrap; white-space: nowrap;
} }
.systemPrompt {
box-sizing: border-box;
}
.toolCatalog {
min-height: 100%;
padding: 8px 0 16px;
}
.toolCatalogItem {
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.toolCatalogSummary {
display: grid;
grid-template-columns: 12px 12px max-content minmax(0, 1fr);
align-items: center;
box-sizing: border-box;
min-height: 30px;
padding: 4px 12px;
gap: 5px;
cursor: pointer;
list-style: none;
user-select: none;
}
.toolCatalogSummary::-webkit-details-marker {
display: none;
}
.toolCatalogSummary:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.toolCatalogSummary:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.toolCatalogChevron,
.toolCatalogIcon {
color: var(--dsw-alias-label-caption);
}
.toolCatalogChevron {
transition: transform 100ms var(--ds-ease-in-out);
}
.toolCatalogItem[open] .toolCatalogChevron {
transform: rotate(90deg);
}
.toolCatalogName {
color: var(--dsw-alias-label-primary);
font: 500 12px/18px var(--ds-font-family-code);
}
.toolCatalogDescription {
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
text-overflow: ellipsis;
white-space: nowrap;
}
.toolCatalogDefinition {
padding: 0 0 8px 29px;
background: var(--dsw-alias-bg-base);
}
.toolCatalogFullDescription {
margin: 0;
padding: 7px 14px 4px 0;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
white-space: pre-wrap;
}
.toolCatalogTree {
margin-left: -14px;
margin-right: 6px;
}
.resultBlocks { .resultBlocks {
display: flex; display: flex;
min-height: 100%; min-height: 100%;

View File

@@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from 'react'
import { import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts' } from './trajectory-record.ts'
@@ -31,7 +32,16 @@ interface TableRecord {
collapsedSummaryKind?: 'turn' | 'assistant' collapsedSummaryKind?: 'turn' | 'assistant'
} }
type DetailTab = 'overview' | 'rendered' | 'source' | 'input' | 'output' | 'schema' | 'timing' type DetailTab =
| 'system-prompt'
| 'tools'
| 'overview'
| 'rendered'
| 'source'
| 'input'
| 'output'
| 'schema'
| 'timing'
type RecordState = 'complete' | 'running' | 'error' type RecordState = 'complete' | 'running' | 'error'
interface DetailTabItem { interface DetailTabItem {
@@ -44,6 +54,11 @@ interface ParentRecords {
tool?: TableRecord tool?: TableRecord
} }
interface ToolCallTextParts {
name: string
args?: string
}
interface DetailsResizeDrag { interface DetailsResizeDrag {
pointerId: number pointerId: number
startX: number startX: number
@@ -61,6 +76,11 @@ const TOOL_REQUEST_MIN_WIDTH = 180
const TOOL_REQUEST_MAX_WIDTH = 480 const TOOL_REQUEST_MAX_WIDTH = 480
const DEFAULT_TOOL_REQUEST_SHARE = 0.36 const DEFAULT_TOOL_REQUEST_SHARE = 0.36
const DEFAULT_TOOL_REQUEST_OFFSET = 56 const DEFAULT_TOOL_REQUEST_OFFSET = 56
const SYSTEM_PROMPT_INDEX = 0
const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
{ id: 'system-prompt', label: 'System Prompt' },
{ id: 'tools', label: 'Tools' },
]
type TrajectorySplitStyle = CSSProperties & { type TrajectorySplitStyle = CSSProperties & {
'--trajectory-tool-request-width': string '--trajectory-tool-request-width': string
@@ -156,6 +176,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
/** Props for the trajectory ledger. */ /** Props for the trajectory ledger. */
export interface TrajectoryTableProps { export interface TrajectoryTableProps {
/** Latest model request header in force for the selected context. */
prompt?: ConversationPromptSnapshot
/** Grouped records in display order. */ /** Grouped records in display order. */
turns: readonly TrajectoryTurnModel[] turns: readonly TrajectoryTurnModel[]
/** Turn ids whose rows after the first are folded into a summary. */ /** Turn ids whose rows after the first are folded into a summary. */
@@ -326,9 +348,21 @@ function statusLabel(state: RecordState): string {
return 'Completed' return 'Completed'
} }
function tokenSummary(cell: TrajectoryCellProps): string { function tokenSummary(cell: TrajectoryCellProps): ReactNode {
if (cell.kind !== 'message') return '—' if (cell.kind !== 'message') return '—'
return `${cell.input ?? '—'} / ${cell.output ?? '—'} / ${cell.think ?? '—'}` if (cell.output === undefined) return '—'
if (cell.think === undefined) return String(cell.output)
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>
)
} }
function isMarkdownRecord(record: TableRecord): boolean { function isMarkdownRecord(record: TableRecord): boolean {
@@ -409,6 +443,19 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
return plainText.replace(/\s+/g, ' ').trim() return plainText.replace(/\s+/g, ' ').trim()
} }
function toolCallTextParts(
kind: TrajectoryCellKind,
text: string,
): ToolCallTextParts | undefined {
if (kind !== 'tool' && kind !== 'subtool') return undefined
const separator = text.indexOf(' · ')
if (separator === -1) return { name: text }
return {
name: text.slice(0, separator),
args: text.slice(separator + 3),
}
}
function isToolCallOnly(cell: TrajectoryCellProps): boolean { function isToolCallOnly(cell: TrajectoryCellProps): boolean {
return cell.kind === 'message' return cell.kind === 'message'
&& !cell.outputDetail && !cell.outputDetail
@@ -581,6 +628,55 @@ function AssistantToolCalls({
) )
} }
function ToolGlyph() {
return (
<svg
className={css.toolCatalogIcon}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function ToolCatalog({ tools }: { tools: ConversationPromptSnapshot['tools'] }) {
if (tools.length === 0) return <p className={css.noPayload}>No tools in this request</p>
return (
<div className={css.toolCatalog}>
{tools.map((tool, index) => (
<details className={css.toolCatalogItem} key={`${tool.name}:${index}`}>
<summary className={css.toolCatalogSummary}>
<IconChevronRightOutline14 className={css.toolCatalogChevron} size={12} />
<ToolGlyph />
<span className={css.toolCatalogName}>{tool.name}</span>
<span className={css.toolCatalogDescription}>{tool.description}</span>
</summary>
<div className={css.toolCatalogDefinition}>
{tool.description !== '' && (
<p className={css.toolCatalogFullDescription}>{tool.description}</p>
)}
<JsonTree
data={tool.parameters}
label={`${tool.name} parameters JSON`}
className={css.toolCatalogTree}
/>
</div>
</details>
))}
</div>
)
}
function ToolOutputBlocks({ function ToolOutputBlocks({
blocks, blocks,
preview, preview,
@@ -879,6 +975,7 @@ function OverviewSection({
* @returns The ledger and an optional local record inspector. * @returns The ledger and an optional local record inspector.
*/ */
export function TrajectoryTable({ export function TrajectoryTable({
prompt,
turns, turns,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
@@ -895,9 +992,17 @@ export function TrajectoryTable({
const allRecords = flattenRecords(turns) const allRecords = flattenRecords(turns)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
const records = collapseAssistantRecords(turnRecords, collapsedAssistants) const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
const systemPromptPreview = prompt === undefined
? 'Request header not recorded'
: prompt.system === ''
? 'No system prompt'
: extractMarkdownPlainText(prompt.system).replace(/\s+/g, ' ').trim()
const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX
const selected = allRecords.find(record => record.cell.index === selectedIndex) const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedState = selected === undefined ? undefined : stateOf(selected) const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedTabs = selected === undefined ? [] : detailTabs(selected) const selectedTabs = promptSelected
? SYSTEM_PROMPT_TABS
: selected === undefined ? [] : detailTabs(selected)
const selectedParents: ParentRecords = selected === undefined const selectedParents: ParentRecords = selected === undefined
? {} ? {}
: parentRecords(allRecords, selected) : parentRecords(allRecords, selected)
@@ -924,6 +1029,11 @@ export function TrajectoryTable({
setActiveTab(recent ?? 'overview') setActiveTab(recent ?? 'overview')
} }
const selectSystemPrompt = () => {
setSelectedIndex(SYSTEM_PROMPT_INDEX)
activateTab('system-prompt')
}
const openRecordSummary = (target: TableRecord) => { const openRecordSummary = (target: TableRecord) => {
const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index) const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn) if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
@@ -954,8 +1064,42 @@ export function TrajectoryTable({
<col className={css.contentColumn} /> <col className={css.contentColumn} />
</colgroup> </colgroup>
<tbody> <tbody>
<tr
tabIndex={0}
aria-label="System prompt and tool catalog"
aria-selected={promptSelected}
data-kind="system"
data-selected={promptSelected || undefined}
onClick={selectSystemPrompt}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
selectSystemPrompt()
}}
>
<td className={css.event}>
{promptSelected && <span className={css.selectionRail} aria-hidden="true" />}
<div className={css.eventInner}>
<span className={`${css.kindSlot} ${css.kindSlotLeft}`}>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
</span>
</div>
</td>
<td className={css.content}>
<span
className={css.contentText}
title={prompt?.system || systemPromptPreview}
>
{systemPromptPreview}
</span>
</td>
</tr>
{records.map((record) => { {records.map((record) => {
const displayText = recordDisplayText(record.cell) const displayText = recordDisplayText(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined const isCollapsedSummary = record.collapsedSummary !== undefined
return ( return (
<tr <tr
@@ -963,7 +1107,7 @@ export function TrajectoryTable({
tabIndex={0} tabIndex={0}
aria-label={isCollapsedSummary aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}` ? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: `${KIND_LABEL[record.cell.kind]}, ${displayText || 'no content'}`} : `${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index} aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index}
data-kind={record.cell.kind} data-kind={record.cell.kind}
data-group-start={record.groupStart || undefined} data-group-start={record.groupStart || undefined}
@@ -1028,7 +1172,18 @@ export function TrajectoryTable({
: `${css.kindSlot} ${css.kindSlotRight}` : `${css.kindSlot} ${css.kindSlotRight}`
} }
> >
<span className={`${css.kindTag} ${css[record.cell.kind]}`}> <span className={`${css.kindTag} ${
record.cell.kind === 'context'
? css.contextGreen
: 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]} {KIND_LABEL[record.cell.kind]}
</span> </span>
{record.turnStart && record.cell.opensTurn && ( {record.turnStart && record.cell.opensTurn && (
@@ -1056,11 +1211,26 @@ export function TrajectoryTable({
<span <span
className={record.cell.result === undefined ? css.contentText : css.resultPreview} className={record.cell.result === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined title={record.cell.result === undefined
? displayText ? listDisplayText
: `${displayText} → ${record.cell.result}`} : `${listDisplayText} → ${record.cell.result}`}
> >
<span className={record.cell.result === undefined ? undefined : css.resultRequest}> <span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell) ? null : displayText || '—'} {isToolCallOnly(record.cell)
? null
: toolCallText === undefined
? listDisplayText || '—'
: (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)}
</span> </span>
{record.cell.result !== undefined && ( {record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}> <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
@@ -1077,7 +1247,7 @@ export function TrajectoryTable({
</tbody> </tbody>
</table> </table>
</div> </div>
{selected !== undefined && selectedState !== undefined && ( {(promptSelected || (selected !== undefined && selectedState !== undefined)) && (
<aside <aside
className={css.details} className={css.details}
aria-label="Event details" aria-label="Event details"
@@ -1159,10 +1329,29 @@ export function TrajectoryTable({
/> />
<div className={css.detailsHeader}> <div className={css.detailsHeader}>
<div className={css.detailsTitle}> <div className={css.detailsTitle}>
<span className={`${css.kindTag} ${css[selected.cell.kind]}`}> {promptSelected
{KIND_LABEL[selected.cell.kind]} ? (
</span> <span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span> )
: selected !== undefined && (
<>
<span className={`${css.kindTag} ${
selected.cell.kind === 'context'
? css.contextGreen
: 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>
<span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span>
</>
)}
</div> </div>
<button <button
type="button" type="button"
@@ -1195,7 +1384,23 @@ export function TrajectoryTable({
role="tabpanel" role="tabpanel"
aria-labelledby={`trajectory-detail-${activeTab}`} aria-labelledby={`trajectory-detail-${activeTab}`}
> >
{activeTab === 'overview' && ( {promptSelected && activeTab === 'system-prompt' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: prompt.system === ''
? <p className={css.noPayload}>No system prompt in this request</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={prompt.system} />
</div>
)
)}
{promptSelected && activeTab === 'tools' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: <ToolCatalog tools={prompt.tools} />
)}
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
<> <>
<dl className={css.overview}> <dl className={css.overview}>
{hasSelectedParents && ( {hasSelectedParents && (
@@ -1205,19 +1410,27 @@ export function TrajectoryTable({
{selectedParents.message !== undefined && ( {selectedParents.message !== undefined && (
<button <button
type="button" type="button"
className={css.overviewHierarchyLink} className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.message!) }} onClick={() => { openRecordSummary(selectedParents.message!) }}
> >
Parent Message <span>Parent Message</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button> </button>
)} )}
{selectedParents.tool !== undefined && ( {selectedParents.tool !== undefined && (
<button <button
type="button" type="button"
className={css.overviewHierarchyLink} className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.tool!) }} onClick={() => { openRecordSummary(selectedParents.tool!) }}
> >
Parent Tool Call <span>Parent Tool Call</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button> </button>
)} )}
</dd> </dd>
@@ -1271,7 +1484,7 @@ export function TrajectoryTable({
</div> </div>
</> </>
)} )}
{activeTab === 'rendered' && ( {!promptSelected && selected !== undefined && activeTab === 'rendered' && (
<MarkdownRecordContent <MarkdownRecordContent
record={selected} record={selected}
rendered rendered
@@ -1280,7 +1493,7 @@ export function TrajectoryTable({
onOpenCall={openCallSummary} onOpenCall={openCallSummary}
/> />
)} )}
{activeTab === 'source' && ( {!promptSelected && selected !== undefined && activeTab === 'source' && (
<MarkdownRecordContent <MarkdownRecordContent
record={selected} record={selected}
rendered={false} rendered={false}
@@ -1289,16 +1502,16 @@ export function TrajectoryTable({
onOpenCall={openCallSummary} onOpenCall={openCallSummary}
/> />
)} )}
{activeTab === 'input' && ( {!promptSelected && selected !== undefined && activeTab === 'input' && (
<RecordPayload record={selected} direction="input" /> <RecordPayload record={selected} direction="input" />
)} )}
{activeTab === 'output' && ( {!promptSelected && selected !== undefined && activeTab === 'output' && (
<RecordPayload record={selected} direction="output" /> <RecordPayload record={selected} direction="output" />
)} )}
{activeTab === 'schema' && ( {!promptSelected && selected !== undefined && activeTab === 'schema' && (
<RecordSchema record={selected} /> <RecordSchema record={selected} />
)} )}
{activeTab === 'timing' && ( {!promptSelected && selected !== undefined && activeTab === 'timing' && (
<RecordTiming record={selected} /> <RecordTiming record={selected} />
)} )}
</div> </div>

View File

@@ -161,17 +161,15 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
/> />
)} )}
<div className={css.ledger}> <div className={css.ledger}>
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>} <TrajectoryTable
{turns.length > 0 && ( key={selectedContext.id}
<TrajectoryTable {...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
key={selectedContext.id} turns={turns}
turns={turns} collapsedTurns={collapsedTurns}
collapsedTurns={collapsedTurns} onToggleTurn={toggleTurn}
onToggleTurn={toggleTurn} collapsedAssistants={collapsedAssistants}
collapsedAssistants={collapsedAssistants} onToggleAssistant={toggleAssistant}
onToggleAssistant={toggleAssistant} />
/>
)}
</div> </div>
</div> </div>
</div> </div>