feat(ui): expand request trajectory inspection

This commit is contained in:
_Kerman
2026-07-27 18:40:30 +08:00
parent ffd2da6986
commit c5be25781c
10 changed files with 522 additions and 151 deletions

View File

@@ -28,7 +28,8 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts' } from './contract/store.ts'
export type { export type {
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
ConversationSnapshot, QueuedMessage, RunningToolCall, ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,

View File

@@ -60,6 +60,23 @@ export interface AssistantTiming {
completedTime: number completedTime: number
} }
/** Request configuration recorded in the effective header for one assistant response. */
export interface AssistantRequestConfig {
provider: string
model: string
thinking?: string
reasoningEffort?: string
temperature?: number
maxTokens?: number
stop?: readonly string[]
}
/** Stable provider/model identity attached to one assistant response. */
export interface AssistantProvenanceView {
provider: string
model: string
}
/** A finalized (or interruption-frozen) assistant message. */ /** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode { export interface AssistantMessageNode {
kind: 'assistant' kind: 'assistant'
@@ -70,6 +87,8 @@ export interface AssistantMessageNode {
step: number step: number
blocks: readonly AssistantBlock[] blocks: readonly AssistantBlock[]
usage?: unknown usage?: unknown
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
/** Timing derived from the recorded step/chunk/message event sequence. */ /** Timing derived from the recorded step/chunk/message event sequence. */
timing?: AssistantTiming timing?: AssistantTiming
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
@@ -211,6 +230,8 @@ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** Latest complete model request header in force within one context generation. */ /** Latest complete model request header in force within one context generation. */
export interface ConversationPromptSnapshot { export interface ConversationPromptSnapshot {
/** Provider/model and sampling configuration from the latest effective request header. */
config?: AssistantRequestConfig
/** Rendered system prompt text; empty when the request had no system prompt. */ /** Rendered system prompt text; empty when the request had no system prompt. */
system: string system: string
/** Complete tool catalog sent with the request, including tools that were never called. */ /** Complete tool catalog sent with the request, including tools that were never called. */

View File

@@ -12,7 +12,7 @@ import {
} from '@deepseek-ai/dsh-session/surface' } from '@deepseek-ai/dsh-session/surface'
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, AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
ConversationPromptSnapshot, ConversationPromptSnapshot,
} from './conversation.ts' } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts'
@@ -43,6 +43,7 @@ function materializeNode(
callIndex: ReadonlyMap<string, CallIndexEntry>, callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null, resultView: ToolResultView | null,
assistantTiming?: AssistantTiming, assistantTiming?: AssistantTiming,
requestConfig?: AssistantRequestConfig,
): ConversationNode { ): ConversationNode {
switch (event.type) { switch (event.type) {
case 'user/message': case 'user/message':
@@ -64,6 +65,11 @@ function materializeNode(
kind: 'assistant', seq: event.seq, time: event.time, kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step, turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
provenance: {
provider: event.data.provenance.provider,
model: event.data.provenance.model,
},
...(requestConfig === undefined ? {} : { requestConfig }),
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
} }
case 'steering/message': case 'steering/message':
@@ -204,6 +210,7 @@ export class FoldAdapter {
this.callIdx, this.callIdx,
this.resultViews.get(seq) ?? null, this.resultViews.get(seq) ?? null,
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined,
) )
this.nodeCache.set(seq, node) this.nodeCache.set(seq, node)
out.push(node) out.push(node)
@@ -280,6 +287,7 @@ export class FoldAdapter {
this.callIdx, this.callIdx,
this.resultViews.get(seq) ?? null, this.resultViews.get(seq) ?? null,
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined,
) )
this.nodeCache.set(seq, node) this.nodeCache.set(seq, node)
return node return node
@@ -312,6 +320,17 @@ export class FoldAdapter {
return { stepStartTime, firstTokenTime, completedTime: event.time } return { stepStartTime, firstTokenTime, completedTime: event.time }
} }
private assistantRequestConfig(
event: SessionEvent<'assistant/message'>,
): AssistantRequestConfig | undefined {
for (let i = event.seq; i >= this.baseSeq; i--) {
const candidate = this.padded[i]
if (candidate?.type !== 'request/header') continue
return candidate.data.header.config
}
return undefined
}
private indexCall(event: SessionEvent, view?: ToolEventView): void { private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') { if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view) if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
@@ -336,6 +355,7 @@ export class FoldAdapter {
} }
if (event.type !== 'request/header') return if (event.type !== 'request/header') return
this.activePrompt = { this.activePrompt = {
config: event.data.header.config,
system: event.data.header.system ?? '', system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [], tools: event.data.header.tools ?? [],
} }

View File

@@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css'
export interface TrajectoryGroupHeaderProps { export interface TrajectoryGroupHeaderProps {
/** Group title (`Message`, `Step 1`, …). */ /** Group title (`Message`, `Step 1`, …). */
title: string title: string
/** Secondary summary (`49s`, `2.2s skill`, …). */ /** Secondary summary (`49 s`, `2.2 s skill`, …). */
description?: string description?: string
} }

View File

@@ -699,6 +699,26 @@
padding-left: 12px; padding-left: 12px;
} }
.usagePanel {
padding: 4px 0 10px;
}
.usageGroup + .usageGroup {
margin-top: 8px;
}
.usageHeading {
margin: 0;
padding: 4px 14px 1px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
.usageGroup .overview {
padding: 0;
}
.overview dt { .overview dt {
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }

View File

@@ -5,7 +5,9 @@ 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 {
AssistantRequestConfig, 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'
@@ -41,6 +43,8 @@ type DetailTab =
| 'input' | 'input'
| 'output' | 'output'
| 'schema' | 'schema'
| 'options'
| 'usage'
| 'timing' | 'timing'
type RecordState = 'complete' | 'running' | 'error' type RecordState = 'complete' | 'running' | 'error'
@@ -89,6 +93,8 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
] ]
const REQUEST_TABS: readonly DetailTabItem[] = [ const REQUEST_TABS: readonly DetailTabItem[] = [
{ id: 'overview', label: 'Summary' }, { id: 'overview', label: 'Summary' },
{ id: 'options', label: 'Options' },
{ id: 'usage', label: 'Usage' },
{ id: 'timing', label: 'Timing' }, { id: 'timing', label: 'Timing' },
] ]
@@ -172,14 +178,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
<div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div> <div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
<div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div> <div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
<div><dt>Throughput</dt><dd>{throughput(metrics)}</dd></div> <div><dt>Throughput</dt><dd>{throughput(metrics)}</dd></div>
<div>
<dt>Output tokens</dt>
<dd>
{!metrics.usageProvided
? 'Usage unavailable'
: metrics.outputTokens ?? 'Output tokens unavailable'}
</dd>
</div>
</dl> </dl>
) )
} }
@@ -188,6 +186,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
export interface TrajectoryTableProps { export interface TrajectoryTableProps {
/** Latest model request header in force for the selected context. */ /** Latest model request header in force for the selected context. */
prompt?: ConversationPromptSnapshot prompt?: ConversationPromptSnapshot
/** Session-global request numbers for the request groups visible in this context. */
requestNumbers?: readonly TrajectoryRequestNumber[]
/** 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. */
@@ -200,6 +200,27 @@ export interface TrajectoryTableProps {
onToggleAssistant(index: number): void onToggleAssistant(index: number): void
} }
/** One context-local request identity paired with its session-global number. */
export interface TrajectoryRequestNumber {
turn: number
step: number
number: number
provider?: string
model?: string
requestConfig?: AssistantRequestConfig
usage?: TrajectoryUsage
cumulativeUsage?: TrajectoryUsage
}
/** Disjoint provider token buckets for one request or a session prefix. */
export interface TrajectoryUsage {
input?: number
cacheRead?: number
cacheWrite?: number
output?: number
reasoning?: number
}
function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
return turns.flatMap((turn) => { return turns.flatMap((turn) => {
let firstInTurn = true let firstInTurn = true
@@ -223,12 +244,35 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
}) })
} }
function requestNumber(group: string): number | undefined { function requestStep(group: string): number | undefined {
if (!group.startsWith('Step ')) return undefined if (!group.startsWith('Step ')) return undefined
const value = Number(group.slice('Step '.length)) const value = Number(group.slice('Step '.length))
return Number.isInteger(value) && value > 0 ? value : undefined return Number.isInteger(value) && value > 0 ? value : undefined
} }
function requestKey(turn: number, group: string): string {
return `${turn}\u0000${group}`
}
function indexRequestNumbers(
records: readonly TableRecord[],
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
): ReadonlyMap<string, number> {
const numbers = new Map<string, number>()
for (const request of sessionNumbers ?? []) {
numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number)
}
let next = Math.max(0, ...numbers.values()) + 1
const boundaries = records
.filter(record => record.groupStart && requestStep(record.group) !== undefined)
.sort((left, right) => left.cell.index - right.cell.index)
for (const record of boundaries) {
const key = requestKey(record.turn, record.group)
if (!numbers.has(key)) numbers.set(key, next++)
}
return numbers
}
function summarizeTurn(records: readonly TableRecord[]): string { function summarizeTurn(records: readonly TableRecord[]): string {
const userText = records const userText = records
.filter(record => record.cell.kind === 'user') .filter(record => record.cell.kind === 'user')
@@ -381,6 +425,94 @@ function tokenSummary(cell: TrajectoryCellProps): ReactNode {
) )
} }
function inputTotal(usage: TrajectoryUsage): number | undefined {
if (
usage.input === undefined
&& usage.cacheRead === undefined
&& usage.cacheWrite === undefined
) return undefined
return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
}
function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
if (usage === undefined) return <p className={css.noPayload}>Usage not reported</p>
const totalInput = inputTotal(usage)
return (
<dl className={css.overview}>
{totalInput !== undefined && (
<div><dt>Input</dt><dd>{totalInput} tok</dd></div>
)}
{usage.cacheRead !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cached</dt>
<dd>{usage.cacheRead} tok</dd>
</div>
)}
{usage.cacheWrite !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cache created</dt>
<dd>{usage.cacheWrite} tok</dd>
</div>
)}
{usage.input !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Other</dt>
<dd>{usage.input} tok</dd>
</div>
)}
{usage.output !== undefined && (
<div><dt>Output</dt><dd>{usage.output} tok</dd></div>
)}
{usage.reasoning !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Reasoning</dt>
<dd>{usage.reasoning} tok</dd>
</div>
)}
</dl>
)
}
function RequestUsagePanel({
usage,
cumulative,
}: {
usage: TrajectoryUsage | undefined
cumulative: TrajectoryUsage | undefined
}) {
return (
<div className={css.usagePanel}>
<section className={css.usageGroup}>
<h4 className={css.usageHeading}>This request</h4>
<UsageRows usage={usage} />
</section>
<section className={css.usageGroup}>
<h4 className={css.usageHeading}>Session cumulative</h4>
<UsageRows usage={cumulative} />
</section>
</div>
)
}
function RequestOptions({
options,
preview = false,
}: {
options: AssistantRequestConfig | undefined
preview?: boolean
}) {
if (options === undefined) {
return <p className={css.noPayload}>Options not recorded</p>
}
return (
<JsonTree
data={options}
label="Request options JSON"
className={preview ? css.jsonPreview! : css.jsonPayload!}
/>
)
}
function isMarkdownRecord(record: TableRecord): boolean { function isMarkdownRecord(record: TableRecord): boolean {
return record.cell.kind === 'user' return record.cell.kind === 'user'
|| record.cell.kind === 'context' || record.cell.kind === 'context'
@@ -435,7 +567,6 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
{ id: 'overview', label: 'Summary' }, { id: 'overview', label: 'Summary' },
{ id: 'rendered', label: 'Preview' }, { id: 'rendered', label: 'Preview' },
{ id: 'source', label: 'Source' }, { id: 'source', label: 'Source' },
{ id: 'timing', label: 'Timing' },
] ]
} }
return [ return [
@@ -1011,6 +1142,7 @@ function OverviewSection({
*/ */
export function TrajectoryTable({ export function TrajectoryTable({
prompt, prompt,
requestNumbers: sessionRequestNumbers,
turns, turns,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
@@ -1026,6 +1158,7 @@ export function TrajectoryTable({
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null) const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview'])) const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
const allRecords = flattenRecords(turns) const allRecords = flattenRecords(turns)
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
const records = collapseAssistantRecords(turnRecords, collapsedAssistants) const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
const systemPromptPreview = prompt === undefined const systemPromptPreview = prompt === undefined
@@ -1060,26 +1193,55 @@ export function TrajectoryTable({
const selectedRequestSubtoolCalls = selectedRequestRecords.filter( const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool', record => record.cell.kind === 'subtool',
).length ).length
const selectedRequestInputTotal = selectedRequestAssistant !== undefined const selectedRequestInfo = selectedRequest === null
&& ( ? undefined
selectedRequestAssistant.cell.input !== undefined : sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
|| selectedRequestAssistant.cell.cacheRead !== undefined const selectedRequestUsage = selectedRequestInfo?.usage ?? (
|| selectedRequestAssistant.cell.cacheWrite !== undefined selectedRequestAssistant === undefined
) ? undefined
? (selectedRequestAssistant.cell.input ?? 0) : {
+ (selectedRequestAssistant.cell.cacheRead ?? 0) ...(selectedRequestAssistant.cell.input === undefined
+ (selectedRequestAssistant.cell.cacheWrite ?? 0) ? {}
: 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
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest?.turn ?? selected?.turn const activeTurn = selectedRequest?.turn ?? selected?.turn
const selectedTabs = selectedRequest !== null const selectedTabs = selectedRequest !== null
? REQUEST_TABS ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: promptSelected : promptSelected
? SYSTEM_PROMPT_TABS ? SYSTEM_PROMPT_TABS
: selected === undefined ? [] : detailTabs(selected) : selected === undefined ? [] : detailTabs(selected)
const selectedParents: ParentRecords = selected === undefined const selectedParents: ParentRecords = selected === undefined
? {} ? {}
: parentRecords(allRecords, selected) : parentRecords(allRecords, selected)
const hasSelectedParents = selectedParents.message !== undefined 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,
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
|| selectedParents.message !== undefined
|| selectedParents.tool !== undefined || selectedParents.tool !== undefined
const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
? undefined ? undefined
@@ -1109,10 +1271,13 @@ export function TrajectoryTable({
activateTab('system-prompt') activateTab('system-prompt')
} }
const selectRequest = (request: SelectedRequest) => { const selectRequest = (
request: SelectedRequest,
tab: 'overview' | 'timing' = 'overview',
) => {
setSelectedIndex(null) setSelectedIndex(null)
setSelectedRequest(request) setSelectedRequest(request)
activateTab('overview') activateTab(tab)
} }
const openRecordSummary = (target: TableRecord) => { const openRecordSummary = (target: TableRecord) => {
@@ -1186,7 +1351,7 @@ export function TrajectoryTable({
const request = record.groupStart const request = record.groupStart
&& !isCollapsedSummary && !isCollapsedSummary
&& !collapsedTurns.has(record.turn) && !collapsedTurns.has(record.turn)
? requestNumber(record.group) ? requestNumbers.get(requestKey(record.turn, record.group))
: undefined : undefined
return ( return (
<tr <tr
@@ -1516,48 +1681,24 @@ export function TrajectoryTable({
<dt>Status</dt> <dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd> <dd>{statusLabel(selectedRequestState)}</dd>
</div> </div>
<div> {(selectedRequestInfo?.provider
<dt>Started</dt> ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
<StartedAtValue
timestamp={selectedRequestAnchor?.cell.startedAt ?? null}
/>
</div>
<div>
<dt>Duration</dt>
<dd>
{formatElapsedSeconds(
selectedRequestAssistant?.cell.timeSeconds ?? null,
)}
</dd>
</div>
{selectedRequestInputTotal !== undefined && (
<div> <div>
<dt>Input</dt> <dt>Provider</dt>
<dd>{selectedRequestInputTotal} tok</dd> <dd>
{selectedRequestInfo?.provider
?? selectedRequestInfo?.requestConfig?.provider}
</dd>
</div> </div>
)} )}
{selectedRequestAssistant?.cell.cacheRead !== undefined && ( {(selectedRequestInfo?.model
<div className={css.requestTokenDetail}> ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
<dt>Cached</dt>
<dd>{selectedRequestAssistant.cell.cacheRead} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.cacheWrite !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cache created</dt>
<dd>{selectedRequestAssistant.cell.cacheWrite} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.input !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Other</dt>
<dd>{selectedRequestAssistant.cell.input} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.output !== undefined && (
<div> <div>
<dt>Output</dt> <dt>Model</dt>
<dd>{selectedRequestAssistant.cell.output} tok</dd> <dd>
{selectedRequestInfo?.model
?? selectedRequestInfo?.requestConfig?.model}
</dd>
</div> </div>
)} )}
<div> <div>
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
<dd>{selectedRequestSubtoolCalls}</dd> <dd>{selectedRequestSubtoolCalls}</dd>
</div> </div>
)} )}
{selectedRequestAssistant !== undefined && (
<div>
<dt>Result</dt>
<dd className={css.overviewParentLinks}>
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
openRecordSummary(selectedRequestAssistant)
}}
>
<span>Assistant Message</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
</dd>
</div>
)}
</dl> </dl>
<div className={css.overviewSections}> <div className={css.overviewSections}>
{selectedRequestAssistant !== undefined && ( {selectedRequestOptions !== undefined && (
<OverviewSection <OverviewSection label="Options" onOpen={() => { activateTab('options') }}>
label="Result" <RequestOptions options={selectedRequestOptions} preview />
onOpen={() => { openRecordSummary(selectedRequestAssistant) }}
>
<MarkdownRecordContent
record={selectedRequestAssistant}
rendered
preview
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
</OverviewSection> </OverviewSection>
)} )}
<OverviewSection label="Usage" onOpen={() => { activateTab('usage') }}>
<UsageRows usage={selectedRequestUsage} />
</OverviewSection>
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}> <OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
<RequestTiming <RequestTiming
assistant={selectedRequestAssistant} assistant={selectedRequestAssistant}
@@ -1596,6 +1750,15 @@ export function TrajectoryTable({
</div> </div>
</> </>
)} )}
{selectedRequest !== null && activeTab === 'options' && (
<RequestOptions options={selectedRequestOptions} />
)}
{selectedRequest !== null && activeTab === 'usage' && (
<RequestUsagePanel
usage={selectedRequestUsage}
cumulative={selectedRequestCumulativeUsage}
/>
)}
{selectedRequest !== null && activeTab === 'timing' && ( {selectedRequest !== null && activeTab === 'timing' && (
<RequestTiming <RequestTiming
assistant={selectedRequestAssistant} assistant={selectedRequestAssistant}
@@ -1621,17 +1784,32 @@ export function TrajectoryTable({
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && ( {!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
<> <>
<dl className={css.overview}> <dl className={css.overview}>
{hasSelectedParents && ( {hasSelectedHierarchy && (
<div> <div>
<dt>Hierarchy</dt> <dt>Hierarchy</dt>
<dd className={css.overviewParentLinks}> <dd className={css.overviewParentLinks}>
{selectedAssistantRequestTarget !== undefined && (
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
)}
{selectedParents.message !== undefined && ( {selectedParents.message !== undefined && (
<button <button
type="button" type="button"
className={css.overviewHierarchyNavLink} className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.message!) }} onClick={() => { openRecordSummary(selectedParents.message!) }}
> >
<span>Parent Message</span> <span>Assistant Message</span>
<IconChevronRightOutline14 <IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight} className={css.overviewHierarchyJumpIconTight}
size={11} size={11}
@@ -1644,7 +1822,7 @@ export function TrajectoryTable({
className={css.overviewHierarchyNavLink} className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.tool!) }} onClick={() => { openRecordSummary(selectedParents.tool!) }}
> >
<span>Parent Tool Call</span> <span>Tool Call</span>
<IconChevronRightOutline14 <IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight} className={css.overviewHierarchyJumpIconTight}
size={11} size={11}
@@ -1661,7 +1839,12 @@ export function TrajectoryTable({
{selected.cell.kind === 'message' && ( {selected.cell.kind === 'message' && (
<div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div> <div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div>
)} )}
<div><dt>Duration</dt><dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd></div> {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div>
)}
</dl> </dl>
<div className={css.overviewSections}> <div className={css.overviewSections}>
{isMarkdownRecord(selected) {isMarkdownRecord(selected)
@@ -1696,9 +1879,21 @@ export function TrajectoryTable({
</OverviewSection> </OverviewSection>
</> </>
)} )}
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}> {selectedAssistantRequestTarget !== undefined && (
<RecordTiming record={selected} /> <OverviewSection
</OverviewSection> label="Timing"
onOpen={() => {
selectRequest(selectedAssistantRequestTarget, 'timing')
}}
>
<RecordTiming record={selected} />
</OverviewSection>
)}
{(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
<RecordTiming record={selected} />
</OverviewSection>
)}
</div> </div>
</> </>
)} )}

View File

@@ -2,15 +2,65 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationContext } from '@deepseek-ai/dsh-client-runtime/client' import type {
AssistantMessageNode, ConversationContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx' import { ContextsPanel, contextLabel } from './ContextsPanel.tsx'
import { TrajectoryTable } from './TrajectoryTable.tsx' import {
TrajectoryTable,
type TrajectoryRequestNumber,
type TrajectoryUsage,
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { deriveTrajectoryLayout } from './layout.ts' import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css' import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set() const EMPTY_IDS: ReadonlySet<number> = new Set()
interface UsageLike {
inputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
const usage = value as UsageLike | undefined
if (usage === undefined) return undefined
return {
...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }),
...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }),
...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }),
...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }),
...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }),
}
}
function addUsage(
total: TrajectoryUsage | undefined,
usage: TrajectoryUsage | undefined,
): TrajectoryUsage | undefined {
if (usage === undefined) return total
return {
...(total?.input === undefined && usage.input === undefined
? {}
: { input: (total?.input ?? 0) + (usage.input ?? 0) }),
...(total?.cacheRead === undefined && usage.cacheRead === undefined
? {}
: { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }),
...(total?.cacheWrite === undefined && usage.cacheWrite === undefined
? {}
: { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }),
...(total?.output === undefined && usage.output === undefined
? {}
: { output: (total?.output ?? 0) + (usage.output ?? 0) }),
...(total?.reasoning === undefined && usage.reasoning === undefined
? {}
: { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }),
}
}
function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext { function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext {
const context = contexts.at(-1) const context = contexts.at(-1)
if (context === undefined) throw new Error('trajectory context projection must not be empty') if (context === undefined) throw new Error('trajectory context projection must not be empty')
@@ -43,6 +93,72 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
: contexts.find(context => context.id === selectedContextId) ?? currentContext : contexts.find(context => context.id === selectedContextId) ?? currentContext
const viewingCurrent = selectedContext.id === currentContext.id const viewingCurrent = selectedContext.id === currentContext.id
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestsBySeq = new Map<number, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
requestsBySeq.set(node.seq, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
requestsBySeq.set(node.seq, node)
}
const orderedRequests = [...requestsBySeq.values()]
.sort((left, right) => left.seq - right.seq)
const requestBySeq = new Map<number, TrajectoryRequestNumber>()
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, node] of orderedRequests.entries()) {
const usage = requestUsage(node.usage)
cumulativeUsage = addUsage(cumulativeUsage, usage)
requestBySeq.set(node.seq, {
turn: node.turn,
step: node.step,
number: index + 1,
...(node.provenance?.provider === undefined
? {}
: { provider: node.provenance.provider }),
...(node.provenance?.model === undefined
? {}
: { model: node.provenance.model }),
...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
const selected: TrajectoryRequestNumber[] = []
const selectedKeys = new Set<string>()
for (const node of selectedNodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
const request = requestBySeq.get(node.seq)
if (request === undefined) continue
selected.push(request)
selectedKeys.add(`${node.turn}\u0000${node.step}`)
}
if (viewingCurrent && partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
if (!selectedKeys.has(key)) {
selected.push({
turn: partial.turn,
step: partial.step,
number: orderedRequests.length + 1,
...(currentContext.prompt?.config?.provider === undefined
? {}
: { provider: currentContext.prompt.config.provider }),
...(currentContext.prompt?.config?.model === undefined
? {}
: { model: currentContext.prompt.config.model }),
...(currentContext.prompt?.config === undefined
? {}
: { requestConfig: currentContext.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return selected
}, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent])
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
const turns = useMemo( const turns = useMemo(
@@ -164,6 +280,7 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
<TrajectoryTable <TrajectoryTable
key={selectedContext.id} key={selectedContext.id}
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }} {...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
requestNumbers={requestNumbers}
turns={turns} turns={turns}
collapsedTurns={collapsedTurns} collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn} onToggleTurn={toggleTurn}

View File

@@ -62,7 +62,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
}} }}
title={lane.timing === 'measured' title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */ /* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s` ? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)} s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`} : lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/> />
</div> </div>

View File

@@ -53,6 +53,15 @@ interface LaidCell {
callId?: string callId?: string
} }
interface LaidGroup {
title: string
laid: LaidCell[]
}
interface TurnBucket {
groups: LaidGroup[]
}
/** /**
* Fold a snapshot into turn → Message/Step groups with expanded cells. * Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls. * @param input - nodes plus in-flight partial/runningCalls.
@@ -70,7 +79,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const startedAt = finiteTime(call.time) const startedAt = finiteTime(call.time)
if (startedAt !== null) callStartById.set(call.callId, startedAt) if (startedAt !== null) callStartById.set(call.callId, startedAt)
} }
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>() const turns = new Map<number, TurnBucket>()
let index = 0 let index = 0
let prevAbsTime: number | null = null let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null let lastAssistantTurn: number | null = null
@@ -78,20 +87,31 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const bucket = (turn: number) => { const bucket = (turn: number) => {
let entry = turns.get(turn) let entry = turns.get(turn)
if (entry === undefined) { if (entry === undefined) {
entry = { message: [], steps: new Map() } entry = { groups: [] }
turns.set(turn, entry) turns.set(turn, entry)
} }
return entry return entry
} }
const pushMessage = (turn: number, laid: LaidCell) => { const pushMessage = (turn: number, laid: LaidCell) => {
bucket(turn).message.push(laid) const groups = bucket(turn).groups
const last = groups.at(-1)
if (last?.title === 'Message') {
last.laid.push(laid)
return
}
groups.push({ title: 'Message', laid: [laid] })
} }
const pushStep = (turn: number, step: number, laid: LaidCell) => { const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
const steps = bucket(turn).steps if (laid.length === 0) return
const list = steps.get(step) ?? [] const groups = bucket(turn).groups
list.push(laid) const title = `Step ${step}`
steps.set(step, list) const existing = groups.find(group => group.title === title)
if (existing !== undefined) {
existing.laid.push(...laid)
return
}
groups.push({ title, laid: [...laid] })
} }
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
@@ -123,10 +143,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches, codeDispatches,
) )
for (const laid of laidList) { if (node.step > 0) pushStep(node.turn, node.step, laidList)
if (node.step > 0) pushStep(node.turn, node.step, laid) else for (const laid of laidList) pushMessage(node.turn, laid)
else pushMessage(node.turn, laid)
}
const last = laidList[laidList.length - 1] const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index if (last !== undefined) index = last.cell.index
prevAbsTime = finiteTime(node.time) ?? prevAbsTime prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -153,7 +171,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'tool-result') { if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) { if (!callEmittedInAssistant(nodes, node.callId)) {
const toolName = node.call?.name const toolName = node.call?.name
pushStep(0, 1, { const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time), absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}), ...(toolName !== undefined ? { toolName } : {}),
callId: node.callId, callId: node.callId,
@@ -172,11 +190,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: durationSeconds(node.time, node.callTime), timeSeconds: durationSeconds(node.time, node.callTime),
startedAt: finiteTime(node.callTime), startedAt: finiteTime(node.callTime),
}, },
}) }]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
pushStep(0, 1, laid) laidList.push(laid)
index = laid.cell.index index = laid.cell.index
} }
pushStep(0, 1, laidList)
} }
prevAbsTime = finiteTime(node.time) ?? prevAbsTime prevAbsTime = finiteTime(node.time) ?? prevAbsTime
} }
@@ -195,10 +214,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
callStartById, callStartById,
{ streaming: true }, { streaming: true },
) )
for (const laid of laidList) { if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
if (partial.step > 0) pushStep(partial.turn, partial.step, laid) else for (const laid of laidList) pushMessage(partial.turn, laid)
else pushMessage(partial.turn, laid)
}
const last = laidList[laidList.length - 1] const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index if (last !== undefined) index = last.cell.index
} }
@@ -206,7 +223,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const seenCalls = collectCallIds(turns) const seenCalls = collectCallIds(turns)
for (const call of runningCalls) { for (const call of runningCalls) {
if (seenCalls.has(call.callId)) continue if (seenCalls.has(call.callId)) continue
pushStep(call.turn, call.step > 0 ? call.step : 1, { const laidList: LaidCell[] = [{
absTime: null, absTime: null,
toolName: call.name, toolName: call.name,
callId: call.callId, callId: call.callId,
@@ -219,34 +236,27 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
timeSeconds: null, timeSeconds: null,
startedAt: finiteTime(call.time), startedAt: finiteTime(call.time),
}, },
}) }]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
pushStep(call.turn, call.step > 0 ? call.step : 1, laid) laidList.push(laid)
index = laid.cell.index index = laid.cell.index
} }
pushStep(call.turn, call.step > 0 ? call.step : 1, laidList)
} }
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
const prologue = turns.get(0) const prologue = turns.get(0)
if (prologue !== undefined) { if (prologue !== undefined) {
turns.delete(0) turns.delete(0)
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({ const emptyTurn = (): TurnBucket => ({ groups: [] })
message: [],
steps: new Map(),
})
const first = turns.get(1) ?? emptyTurn() const first = turns.get(1) ?? emptyTurn()
first.message = [...prologue.message, ...first.message] first.groups = [...prologue.groups, ...first.groups]
for (const [step, cells] of prologue.steps) {
const existing = first.steps.get(step) ?? []
first.steps.set(step, [...cells, ...existing])
}
turns.set(1, first) turns.set(1, first)
} }
for (const entry of turns.values()) { for (const entry of turns.values()) {
for (const laid of entry.message) attachToolSchema(laid, callSchemas) for (const group of entry.groups) {
for (const laid of entry.steps.values()) { for (const laid of group.laid) attachToolSchema(laid, callSchemas)
for (const cell of laid) attachToolSchema(cell, callSchemas)
} }
} }
@@ -267,30 +277,20 @@ function attachToolSchema(
function toTurnModel( function toTurnModel(
turn: number, turn: number,
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> }, entry: TurnBucket,
): TrajectoryTurnModel { ): TrajectoryTurnModel {
const groups: TrajectoryGroupModel[] = [] const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
if (entry.message.length > 0) {
const description = groupDescription(entry.message)
groups.push({
title: 'Message',
...(description !== undefined ? { description } : {}),
cells: entry.message.map(l => l.cell),
})
}
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
const laid = entry.steps.get(step) ?? []
const description = groupDescription(laid) const description = groupDescription(laid)
groups.push({ return {
title: `Step ${step}`, title,
...(description !== undefined ? { description } : {}), ...(description !== undefined ? { description } : {}),
cells: laid.map(l => l.cell), cells: laid.map(l => l.cell),
}) }
} })
return { turn, groups } return { turn, groups }
} }
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ /** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined { function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = [] const parts: string[] = []
// Tool rows contribute start (absTime) and end (start + own duration) so a // Tool rows contribute start (absTime) and end (start + own duration) so a
@@ -325,8 +325,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | undefined { function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10 const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s` if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)}s` return `${rounded.toFixed(1)} s`
} }
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -551,15 +551,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
} }
function collectCallIds( function collectCallIds(
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>, turns: Map<number, TurnBucket>,
): Set<string> { ): Set<string> {
const ids = new Set<string>() const ids = new Set<string>()
for (const entry of turns.values()) { for (const entry of turns.values()) {
for (const laid of entry.message) { for (const group of entry.groups) {
if (laid.callId !== undefined) ids.add(laid.callId) for (const laid of group.laid) {
}
for (const list of entry.steps.values()) {
for (const laid of list) {
if (laid.callId !== undefined) ids.add(laid.callId) if (laid.callId !== undefined) ids.add(laid.callId)
} }
} }

View File

@@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
export function formatElapsedSeconds(seconds: number | null): string { export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—' if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10 const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s` if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)}s` return `${rounded.toFixed(1)} s`
} }