feat(ui): add trajectory inspection ledger

This commit is contained in:
_Kerman
2026-07-27 15:47:44 +08:00
parent 79eb3a9035
commit 628c1bffe0
42 changed files with 4549 additions and 178 deletions

View File

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

View File

@@ -3,7 +3,7 @@
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
@@ -50,6 +50,16 @@ export interface UserMessageNode {
source: unknown
}
/** Recorded boundaries used to derive assistant latency and throughput. */
export interface AssistantTiming {
/** Matching step/start timestamp, or null when it is outside the current event window. */
stepStartTime: number | null
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
firstTokenTime: number | null
/** Final assistant/message timestamp. */
completedTime: number
}
/** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode {
kind: 'assistant'
@@ -60,6 +70,8 @@ export interface AssistantMessageNode {
step: number
blocks: readonly AssistantBlock[]
usage?: unknown
/** Timing derived from the recorded step/chunk/message event sequence. */
timing?: AssistantTiming
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
interrupted?: true
@@ -216,6 +228,8 @@ export interface ConversationSnapshot {
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Model-visible tool schema captured for each recorded call id. */
callSchemas?: ReadonlyMap<string, ToolSchema>
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
queue: readonly QueuedMessage[]

View File

@@ -9,7 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import type { AssistantTiming, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -37,6 +37,7 @@ function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
assistantTiming?: AssistantTiming,
): ConversationNode {
switch (event.type) {
case 'user/message':
@@ -58,6 +59,7 @@ function materializeNode(
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
}
case 'steering/message':
return {
@@ -177,7 +179,12 @@ export class FoldAdapter {
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
const node = materializeNode(
event,
this.callIdx,
this.resultViews.get(seq) ?? null,
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
)
this.nodeCache.set(seq, node)
out.push(node)
}
@@ -196,6 +203,33 @@ export class FoldAdapter {
return seqs
}
private assistantTiming(event: SessionEvent<'assistant/message'>): AssistantTiming {
let stepStartTime: number | null = null
let firstTokenTime: number | null = null
for (let i = this.baseSeq; i < this.padded.length; i++) {
const candidate = this.padded[i]
if (candidate === undefined || candidate.seq > event.seq) break
if (
candidate.type === 'step/start'
&& candidate.data.turn === event.data.turn
&& candidate.data.step === event.data.step
) {
stepStartTime = candidate.time
continue
}
if (
firstTokenTime === null
&& candidate.type === 'assistant/chunk'
&& candidate.data.turn === event.data.turn
&& candidate.data.step === event.data.step
&& isTokenDelta(candidate.data.chunk)
) {
firstTokenTime = candidate.time
}
}
return { stepStartTime, firstTokenTime, completedTime: event.time }
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
@@ -211,3 +245,15 @@ export class FoldAdapter {
// (window order puts the call before its result; cannot happen on the normal path).
}
}
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}

View File

@@ -1,7 +1,7 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
@@ -104,6 +104,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
/** Schemas in force for the next tool/call, updated by request/header. */
private activeToolSchemas = new Map<string, ToolSchema>()
/** Call-time schema snapshots keyed by native or code-dispatch call id. */
private callSchemas = new Map<string, ToolSchema>()
private callSchemasRev = 0
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -611,6 +617,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
this.captureCallSchema(data.subCallId, data.name)
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
@@ -630,6 +637,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.captureCallSchema(data.subCallId, data.name)
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
@@ -651,6 +659,12 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
switch (event.type) {
case 'request/header': {
this.activeToolSchemas = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
)
return
}
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -666,6 +680,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'tool/call': {
this.captureCallSchema(String(event.data.callId), event.data.name)
this.openCalls.set(String(event.data.callId), {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time,
@@ -720,6 +735,15 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Preserve the schema active when one call starts. */
private captureCallSchema(callId: string, name: string): void {
if (this.callSchemas.has(callId)) return
const schema = this.activeToolSchemas.get(name)
if (schema === undefined) return
this.callSchemas.set(callId, schema)
this.callSchemasRev++
}
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
@@ -731,6 +755,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
this.activeToolSchemas = new Map()
this.callSchemas = new Map()
this.callSchemasRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -766,6 +793,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.callSchemasCache === null || this.callSchemasCache.rev !== this.callSchemasRev) {
this.callSchemasCache = { rev: this.callSchemasRev, value: new Map(this.callSchemas) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
@@ -778,6 +808,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
callSchemas: this.callSchemasCache.value,
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(

View File

@@ -72,8 +72,8 @@ export function apply(ctx: Context): void {
},
}), 'ui-conversation: input standard-kit provider')
// Resident current-session-optional shell. It owns the stable Hero/composer
// frame while strict session slots fill only their session-bound regions.
// Resident current-session-optional shell. It constructs the stable
// composer frame; the strict session child decides whether Chat mounts it.
slots.register({
name: 'conversation',
children: {
@@ -106,8 +106,8 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
// The strict session subtree owns the per-session store and view content;
// the resident parent supplies the composer node through owner props.
slots.register({
name: 'conversation.session',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },

View File

@@ -107,6 +107,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/** Composer chain assembled by the resident parent; only Chat mounts it. */
composer: ReactNode
}
/**

View File

@@ -74,6 +74,11 @@ export function ConversationRoot({
{inputBar}
</div>
)
const composer = renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)
return (
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
@@ -81,12 +86,9 @@ export function ConversationRoot({
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot('conversation.session', {})}
{renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)}
{sessionId !== undefined
? renderSlot('conversation.session', { composer })
: composer}
</div>
)
}

View File

@@ -1,6 +1,6 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
import { useEffect, useSyncExternalStore } from 'react'
import { Fragment, useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open,
renderSlot, views, bindDraftMirror, open, composer,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -45,11 +45,11 @@ export function ConversationSession({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputActions])
if (blank && composerPhase === 'blank') return null
const blankHero = blank && composerPhase === 'blank'
return (
<>
<header className={css.header}>
{!blankHero && <header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
@@ -88,10 +88,11 @@ export function ConversationSession({
))}
</div>
)}
</header>
<div className={css.viewArea}>
</header>}
{!blankHero && <div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
</div>}
{(blankHero || active?.id === 'chat') && <Fragment key="composer">{composer}</Fragment>}
</>
)
}

View File

@@ -54,12 +54,12 @@
Handle component; the hit strip stays wider than the pill. */
.handle {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
width: 8px;
margin-left: -4px;
cursor: col-resize;
z-index: 2;
touch-action: none;
/* Rides the same curve as the tracks so the pill stays on the moving
border during collapse/expand; paused while dragging (frame rule). */

View File

@@ -22,8 +22,12 @@
"dependencies": {
"@shikijs/langs": "^4.3.1",
"clsx": "^2.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-json-view-lite": "^2.5.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.3.1"

View File

@@ -0,0 +1,221 @@
.root {
--json-tree-property: #881391;
--json-tree-string: #c41a16;
--json-tree-number: #1c00cf;
--json-tree-keyword: #1c00cf;
--json-tree-punctuation: #202124;
--json-tree-icon: #5f6368;
--json-tree-hover: rgb(60 64 67 / 4%);
min-width: 0;
overflow: auto;
position: relative;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: 12px/16px var(--ds-font-family-code);
overscroll-behavior: contain;
}
:global(body[data-ds-dark-theme]) .root {
--json-tree-property: #5db0d7;
--json-tree-string: #f28b82;
--json-tree-number: #99c8ff;
--json-tree-keyword: #99c8ff;
--json-tree-punctuation: #e8eaed;
--json-tree-icon: #9aa0a6;
--json-tree-hover: rgb(232 234 237 / 5%);
}
.container {
box-sizing: border-box;
width: max-content;
min-width: 100%;
margin: 0;
padding: 6px 8px 8px;
white-space: pre;
}
.expandedTopLevel {
box-sizing: border-box;
width: max-content;
min-width: 100%;
padding: 6px 8px 8px 14px;
}
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
background: var(--json-tree-hover);
}
.expandedTopLevelContainer {
padding: 0 0 0 calc(2ch - 12px);
}
.row.topLevelBracket {
margin-left: 0;
padding-left: 0;
}
.children {
margin: 0;
padding: 0 0 0 4px;
list-style: none;
}
.row {
position: relative;
box-sizing: border-box;
min-width: 100%;
min-height: 16px;
margin: 0;
padding: 0 0 0 12px;
list-style: none;
}
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
.row:not(.topLevelBracket)[data-json-copy-active]::after,
.row:has(> .expander:focus-visible)::after {
position: absolute;
z-index: 0;
top: 0;
right: -100vw;
left: -100vw;
height: 16px;
background: var(--json-tree-hover);
content: '';
pointer-events: none;
}
.row > span:not(.expander) {
position: relative;
z-index: 1;
}
.label {
margin-right: 3px;
color: var(--json-tree-property);
font-weight: 400;
}
.clickableLabel {
cursor: pointer;
}
.stringValue {
color: var(--json-tree-string);
}
.numberValue {
color: var(--json-tree-number);
}
.keywordValue {
color: var(--json-tree-keyword);
}
.otherValue {
color: var(--dsw-alias-label-secondary);
}
.punctuation {
color: var(--json-tree-punctuation);
}
.preview {
color: var(--json-tree-punctuation);
}
.previewProperty {
color: var(--json-tree-punctuation);
}
.previewEllipsis {
color: var(--dsw-alias-label-tertiary);
}
.copyAnchor {
position: absolute;
z-index: 3;
display: inline-flex;
}
.copyButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.copyButton:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.copyButton:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.copyButton[data-state='failed'] {
color: var(--dsw-alias-state-error-primary);
}
.expander {
position: absolute;
z-index: 2;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 8px;
height: 16px;
margin: 0;
color: var(--json-tree-icon);
cursor: pointer;
user-select: none;
}
.expander::before {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid currentColor;
content: '';
transform: scale(0.75);
transform-origin: center;
}
.collapseIcon::before {
transform: rotate(90deg) scale(0.75);
}
.expander:hover {
color: var(--dsw-alias-label-primary);
}
.expander:focus-visible {
outline: none;
}
.collapsedContent {
margin: 0 1px;
color: var(--json-tree-punctuation);
cursor: pointer;
}
.collapsedContent::after {
content: '…';
}

View File

@@ -0,0 +1,390 @@
import clsx from 'clsx'
import { collapseAllNested, JsonView } from 'react-json-view-lite'
import { useEffect, useRef, useState } from 'react'
import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react'
import type { Props as LiteJsonViewProps } from 'react-json-view-lite'
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
import { Menu } from './Menu.tsx'
import type { MenuEntry } from './Menu.tsx'
import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
const TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
container: css.container!,
childFieldsContainer: css.children!,
basicChildStyle: css.row!,
label: css.label!,
clickableLabel: `${css.label!} ${css.clickableLabel!}`,
nullValue: css.keywordValue!,
undefinedValue: css.keywordValue!,
numberValue: css.numberValue!,
stringValue: css.stringValue!,
booleanValue: css.keywordValue!,
otherValue: css.otherValue!,
punctuation: css.punctuation!,
expandIcon: `${css.expander!} ${css.expandIcon!}`,
collapseIcon: `${css.expander!} ${css.collapseIcon!}`,
collapsedContent: css.collapsedContent!,
noQuotesForStringValues: false,
quotesForFieldNames: false,
stringifyStringValues: true,
ariaLables: {
collapseJson: 'Collapse JSON node',
expandJson: 'Expand JSON node',
},
}
const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
...TREE_STYLES,
container: `${css.container!} ${css.expandedTopLevelContainer!}`,
}
function previewPrimitive(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
return <span className={css.otherValue}>{String(value)}</span>
}
function previewValue(value: unknown, depth: number): ReactNode {
if (typeof value !== 'object' || value === null) return previewPrimitive(value)
const array = Array.isArray(value)
const entries = array
? value.map((item, index) => [String(index), item] as const)
: Object.entries(value)
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
const visible = entries.slice(0, limit)
const open = array ? '[' : '{'
const close = array ? ']' : '}'
return (
<>
<span className={css.punctuation}>{open}</span>
{depth >= PREVIEW_DEPTH_LIMIT
? <span className={css.previewEllipsis}></span>
: visible.map(([key, item], index) => (
<span key={key}>
{index > 0 && <span className={css.punctuation}>, </span>}
{!array && (
<>
<span className={css.previewProperty}>{key}</span>
<span className={css.punctuation}>: </span>
</>
)}
{previewValue(item, depth + 1)}
</span>
))}
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
<span className={css.previewEllipsis}>{visible.length > 0 ? ', …' : '…'}</span>
)}
<span className={css.punctuation}>{close}</span>
</>
)
}
function renderExpandableValue(value: object): ReactNode {
return <span className={css.preview}>{previewValue(value, 0)}</span>
}
interface CopyTarget {
left: number
path: readonly (number | string)[]
side: 'bottom' | 'top'
top: number
value: unknown
}
function fieldOf(row: HTMLElement): string | undefined {
const label = Array.from(row.children).find(
child => child instanceof HTMLElement && child.classList.contains(css.label!),
)
const text = label?.textContent
return text === undefined || text === null ? undefined : text.slice(0, -1)
}
function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): {
path: readonly (number | string)[]
value: unknown
} | undefined {
if (row.hasAttribute('data-json-root-row')) return { path: [], value: data }
const lineage: HTMLElement[] = []
let cursor: HTMLElement | null = row
while (cursor !== null) {
lineage.unshift(cursor)
const group: HTMLElement | null = cursor.parentElement
const parentRow: Element | null = group?.getAttribute('role') === 'group'
? group.parentElement?.closest('[role="treeitem"]') ?? null
: null
cursor = parentRow instanceof HTMLElement ? parentRow : null
}
let value: unknown = data
const path: (number | string)[] = []
for (const item of expandTopLevel ? lineage : lineage.slice(1)) {
const field = fieldOf(item)
if (field === undefined) return undefined
if (Array.isArray(value)) {
const index = Number(field)
if (!Number.isInteger(index)) return undefined
path.push(index)
value = value[index]
} else if (typeof value === 'object' && value !== null) {
path.push(field)
value = (value as Record<string, unknown>)[field]
} else {
return undefined
}
}
return { path, value }
}
function formattedPath(path: readonly (number | string)[]): string {
return path.reduce<string>((result, part) => {
if (typeof part === 'number') return `${result}[${String(part)}]`
return /^[A-Za-z_$][\w$]*$/.test(part)
? `${result}.${part}`
: `${result}[${JSON.stringify(part)}]`
}, '$')
}
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
if (mode === 'path') return formattedPath(target.path)
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value)
if (typeof target.value === 'string') return target.value
if (typeof target.value === 'object' && target.value !== null) {
return JSON.stringify(target.value, null, 2)
}
return JSON.stringify(target.value) ?? String(target.value)
}
/** Props for the read-only, token-themed JSON tree. */
export interface JsonTreeProps {
/** Parsed JSON object or array. */
data: object | unknown[]
/** Accessible label for the tree. */
label?: string
/** Optional positioning class owned by the caller. */
className?: string | undefined
/** Whether JSON rows expose copy actions. */
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
}
/**
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
* @param props - Parsed data, accessible label, and display options.
* @returns A read-only JSON tree with an optionally fixed-open top level.
*/
export function JsonTree({
data,
label = 'JSON',
className,
copyable = true,
expandTopLevel = true,
}: JsonTreeProps) {
const rootRef = useRef<HTMLDivElement>(null)
const activeRowRef = useRef<HTMLElement>()
const copyButtonRef = useRef<HTMLButtonElement>(null)
const copyMenuOpenRef = useRef(false)
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
useEffect(() => () => {
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
activeRowRef.current?.removeAttribute('data-json-copy-active')
}, [])
const setActiveRow = (row: HTMLElement | undefined) => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = row
row?.setAttribute('data-json-copy-active', '')
}
const positionCopyButton = (row: HTMLElement, target: {
path: readonly (number | string)[]
value: unknown
}) => {
const root = rootRef.current
if (root === null) return
const rootRect = root.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
setCopyTarget({
left: root.scrollLeft + root.clientWidth - 26,
path: target.path,
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
top: root.scrollTop + rowRect.top - rootRect.top,
value: target.value,
})
}
const clearCopyTarget = () => {
setActiveRow(undefined)
setCopyTarget(undefined)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}
const handleMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!copyable || !(event.target instanceof Element)) return
if (copyMenuOpenRef.current) return
if (!event.currentTarget.contains(event.target)) return
if (event.target.closest('[data-json-copy-button]') !== null) return
const row = event.target.closest<HTMLElement>('[data-json-root-row], [role="treeitem"]')
if (row === null) {
clearCopyTarget()
return
}
if (activeRowRef.current === row) return
const resolved = resolveRow(data, row, expandTopLevel)
if (resolved === undefined) return
setActiveRow(row)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
positionCopyButton(row, resolved)
}
const handleScroll = (event: ReactUIEvent<HTMLDivElement>) => {
if (event.currentTarget !== event.target) return
const row = activeRowRef.current
if (row === undefined) return
const resolved = resolveRow(data, row, expandTopLevel)
if (resolved !== undefined) positionCopyButton(row, resolved)
}
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
if (copyTarget === undefined) return
try {
await navigator.clipboard.writeText(copyText(copyTarget, mode))
setCopyState('copied')
} catch {
setCopyState('failed')
}
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500)
}
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
return (
<div
ref={rootRef}
className={clsx(css.root, className)}
onMouseOver={handleMouseOver}
onMouseLeave={() => {
if (!copyMenuOpenRef.current) clearCopyTarget()
}}
onScroll={handleScroll}
>
{expandTopLevel
? (
<div className={css.expandedTopLevel}>
<div className={clsx(css.row, css.topLevelBracket)} data-json-root-row>
<span className={css.punctuation}>{Array.isArray(data) ? '[' : '{'}</span>
</div>
<JsonView
aria-label={label}
compactTopLevel
data={data}
style={EXPANDED_TOP_LEVEL_TREE_STYLES}
shouldExpandNode={collapseAllNested}
clickToExpandNode
renderExpandableValue={renderExpandableValue}
/>
<div className={clsx(css.row, css.topLevelBracket)}>
<span className={css.punctuation}>{Array.isArray(data) ? ']' : '}'}</span>
</div>
</div>
)
: (
<JsonView
aria-label={label}
data={data}
style={TREE_STYLES}
shouldExpandNode={collapseAllNested}
clickToExpandNode
renderExpandableValue={renderExpandableValue}
/>
)}
{copyTarget !== undefined && (
<span
className={css.copyAnchor}
style={{ left: copyTarget.left, top: copyTarget.top }}
>
<Menu
open={copyMenuOpen}
compact
portal
align="end"
side={copyTarget.side}
anchor={(
<button
ref={copyButtonRef}
type="button"
className={css.copyButton}
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
copyMenuOpenRef.current = true
setCopyMenuOpen(true)
}}
>
{copyState === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
onSelect={(id) => {
if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') {
void copy(id)
}
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}}
onClose={clearCopyTarget}
getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null}
/>
</span>
)}
</div>
)
}

View File

@@ -77,6 +77,37 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.list.compactList,
.submenu.compactList {
min-width: 164px;
padding: 2px;
border-radius: 7px;
}
.compactList .item {
min-height: 26px;
gap: 6px;
padding: 3px 7px;
border-radius: 5px;
font-size: 12px;
line-height: 18px;
}
.compactList .itemIcon {
width: 14px;
height: 14px;
}
.compactList .separator {
margin: 2px;
}
.compactList .label {
padding: 4px 7px;
font-size: 11px;
line-height: 16px;
}
.item:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;

View File

@@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* scroll/resize; return null to skip placement for that frame.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
side?: 'bottom' | 'top'
portal?: boolean
closeOnPointerLeave?: boolean
compact?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
const list = open && (!portal || fixedPos !== null) && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, compact && css.compactList, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
@@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}

View File

@@ -17,8 +17,12 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
export * from './icons/index.tsx'

View File

@@ -0,0 +1,126 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML is intentionally
* omitted, links keep their labels, images keep alt text, and code keeps its
* source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
/** Amount of parsed Markdown content returned by the extractor. */
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
/** Options for {@link extractMarkdownPlainText}. */
export interface MarkdownPlainTextOptions {
/** Projection boundary; defaults to the complete document. */
mode?: MarkdownPlainTextMode
}
interface MarkdownNode {
type: string
value?: string
alt?: string
children?: MarkdownNode[]
}
function inlineText(node: MarkdownNode): string {
switch (node.type) {
case 'text':
case 'inlineCode':
case 'code':
return node.value ?? ''
case 'image':
case 'imageReference':
return node.alt ?? ''
case 'break':
return '\n'
case 'html':
case 'thematicBreak':
case 'definition':
return ''
default:
return node.children?.map(inlineText).join('') ?? ''
}
}
function compactInline(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
function blockText(node: MarkdownNode): string {
switch (node.type) {
case 'root':
case 'blockquote':
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
case 'paragraph':
case 'heading':
return compactInline(inlineText(node))
case 'code':
return node.value?.trim() ?? ''
case 'list':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'listItem':
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
case 'table':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'tableRow':
return node.children?.map(blockText).join('\t') ?? ''
case 'tableCell':
return compactInline(inlineText(node))
case 'html':
case 'thematicBreak':
case 'definition':
return ''
default:
return compactInline(inlineText(node))
}
}
function findFirstParagraph(node: MarkdownNode): string | undefined {
if (node.type === 'paragraph') {
const text = compactInline(inlineText(node))
if (text !== '') return text
}
for (const child of node.children ?? []) {
const text = findFirstParagraph(child)
if (text !== undefined) return text
}
return undefined
}
function fullText(root: MarkdownNode): string {
return blockText(root)
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/**
* Parse GFM Markdown and project its user-visible plain text.
* @param markdown - Markdown source.
* @param options - Optional extraction boundary.
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
*/
export function extractMarkdownPlainText(
markdown: string,
options: MarkdownPlainTextOptions = {},
): string {
const { mode = 'all' } = options
const root = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
}) as MarkdownNode
const all = fullText(root)
switch (mode) {
case 'all':
return all
case 'first-line':
return all.split('\n').find(line => line !== '') ?? ''
case 'first-paragraph':
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
}
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
const MARKDOWN = [
'# Release notes',
'',
'First **paragraph** with [a link](https://example.com) and ![diagram](diagram.png).',
'',
'- shipped',
'- `verified`',
'',
'```ts',
'const ready = true',
'```',
].join('\n')
describe('extractMarkdownPlainText', () => {
it('projects the complete GFM document without presentation syntax', () => {
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
'Release notes',
'',
'First paragraph with a link and diagram.',
'',
'shipped',
'verified',
'',
'const ready = true',
].join('\n'))
})
it('selects the first visible line or first semantic paragraph', () => {
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
.toBe('First paragraph with a link and diagram.')
})
it('omits raw HTML and returns a useful fallback when no paragraph exists', () => {
const markdown = '<script>alert(1)</script>\n\n## Safe heading'
expect(extractMarkdownPlainText(markdown)).toBe('Safe heading')
expect(extractMarkdownPlainText(markdown, { mode: 'first-paragraph' })).toBe('Safe heading')
})
})

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
README.zh.md: da14d5265c16acb4e75d21bc445ef9702c83e697
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 884db29b746124ecc1d7a8d2312101b2930c113f
README.zh.md: a4d7d4513317692798bb6ea8b453293fa05b6cbe

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. The package also provides the Waterfall view and remains a pure-consumer plugin (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands; record selection is intentionally local to Trajectory; anchor deep-linking remains deferred.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
轨迹轮次列表 chrome吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall 占位符;这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。该包package还提供 waterfall瀑布式事件视图并保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册两个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验
@@ -14,4 +14,4 @@
## 已知限制与暂缓事项
- **进行中的 Time 保持空白**`partial``runningCalls`在实时钟策略落地前渲染为 `—`;选中样式只在本地生效(未连接到聊天详情);锚点深链接仍暂缓实现。
- **进行中的 Time 保持空白**`partial``runningCalls`会显示运行状态,但在实时钟策略落地前不会虚构耗时;记录选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-trajectory",
"description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
"description": "Trajectory event-ledger and Waterfall views: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -35,11 +35,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -1,12 +1,18 @@
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
// ellipsis text, optional Message token metrics, and own-duration time.
// Legacy standalone trajectory cell retained for direct consumers and specs.
import type { HTMLAttributes } from 'react'
import {
formatElapsedSeconds,
type TrajectoryCellKind,
type TrajectoryCellProps,
} from './trajectory-record.ts'
import css from './TrajectoryCell.module.css'
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think;
* subtool = one run_code sub-dispatch nested under its Tool cell). */
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
export { formatElapsedSeconds }
export type {
AssistantMetricDetail,
TrajectoryCellKind,
TrajectoryCellProps,
} from './trajectory-record.ts'
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -23,40 +29,6 @@ const TAG_CLASS: Record<TrajectoryCellKind, string> = {
subtool: css.tagSubtool!,
}
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based step index shown as `#N`. */
index: number
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
/**
* Own duration in seconds. `null` means no duration to show (em dash) —
* used for in-flight tools and tools missing callTime.
*/
timeSeconds: number | null
/** Message-only: prompt token count. */
input?: number
/** Message-only: completion token count. */
output?: number
/** Message-only: reasoning token count (usage column, not a Think cell). */
think?: number
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
selected?: boolean
}
/**
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
* or `+N.1s` otherwise.
* @param seconds - duration seconds, or null when absent.
* @returns display string.
*/
export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `+${rounded}s`
return `+${rounded.toFixed(1)}s`
}
/**
* Render one trajectory step cell.
* @param props - index, kind, text, time, and optional Message metrics.
@@ -66,7 +38,18 @@ export function TrajectoryCell({
index,
kind,
text,
inputDetail: _inputDetail,
outputDetail: _outputDetail,
thinkingDetail: _thinkingDetail,
sourceBlocks: _sourceBlocks,
outputBlocks: _outputBlocks,
schemaDetail: _schemaDetail,
assistantMetrics: _assistantMetrics,
result: _result,
callId: _callId,
isError: _isError,
timeSeconds,
startedAt: _startedAt,
input,
output,
think,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
.root {
position: sticky;
top: 0;
z-index: 4;
box-sizing: border-box;
width: 100%;
height: var(--dsh-trajectory-toolbar-height);
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
}
.inner {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 14px 0 16px;
}
.summary {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.title {
flex: none;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-strong-13);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
}
.action {
display: inline-flex;
flex: none;
align-items: center;
box-sizing: border-box;
height: 26px;
padding: 0 7px;
gap: 6px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xs-13);
transition:
color 120ms var(--ds-ease-in-out),
background-color 120ms var(--ds-ease-in-out);
}
.action:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.action:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.actionIcon {
color: var(--dsw-alias-label-tertiary);
font: 13px/13px var(--ds-font-family-code);
}
@media (max-width: 720px) {
.summary {
gap: 7px;
}
}

View File

@@ -0,0 +1,66 @@
/** Trajectory toolbar: view identity, record totals, and the ledger fold control. */
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
allTurnsCollapsed: boolean
/** Fold or expand every collapsible turn. */
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
}
/**
* Render the sticky trajectory toolbar.
* @param props - rendered counts and whole-list fold state.
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
collapsibleAssistants,
allAssistantsCollapsed,
onToggleAllAssistants,
}: TrajectoryToolbarProps) {
return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
<div className={css.inner}>
<div className={css.summary}>
<span className={css.title}>Trajectory</span>
</div>
<div className={css.actions}>
<button
type="button"
className={css.action}
disabled={collapsibleAssistants === 0}
onClick={onToggleAllAssistants}
>
<span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'}
</span>
{allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
</button>
<button
type="button"
className={css.action}
disabled={collapsibleTurns === 0}
onClick={onToggleAllTurns}
>
<span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'}
</span>
{allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,41 +1,110 @@
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryCell } from './TrajectoryCell.tsx'
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
import { TrajectoryTable } from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(() => new Set())
const [collapsedAssistants, setCollapsedAssistants] = useState<ReadonlySet<number>>(() => new Set())
const nodes = useSession((s) => s.nodes)
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 turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches],
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, callSchemas, codeDispatches }),
[nodes, partial, runningCalls, callSchemas, codeDispatches],
)
if (turns.length === 0) {
return <div className={css.root}><p className={css.empty}></p></div>
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1)
.map(turn => turn.turn),
[turns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
if (cell?.kind !== 'message') continue
const next = cells[i + 1]
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
}
}
return ids
}, [turns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => {
setCollapsedTurns((current) => {
const next = new Set(current)
if (next.has(turn)) next.delete(turn)
else next.add(turn)
return next
})
}
const toggleAllTurns = () => {
setCollapsedTurns((current) => {
const next = new Set(current)
if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) next.delete(turn)
} else {
for (const turn of collapsibleTurnIds) next.add(turn)
}
return next
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistants((current) => {
const next = new Set(current)
if (next.has(index)) next.delete(index)
else next.add(index)
return next
})
}
const toggleAllAssistants = () => {
setCollapsedAssistants((current) => {
const next = new Set(current)
if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) next.delete(index)
} else {
for (const index of collapsibleAssistantIds) next.add(index)
}
return next
})
}
return (
<div className={css.root}>
{turns.map((turn) => (
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
{turn.groups.flatMap((group) => [
<TrajectoryGroupHeader
key={`${group.title}-h`}
title={group.title}
{...(group.description !== undefined ? { description: group.description } : {})}
/>,
...group.cells.map((cell) => (
<TrajectoryCell key={cell.index} {...cell} />
)),
])}
</TrajectoryTurn>
))}
<TrajectoryToolbar
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
collapsibleAssistants={collapsibleAssistantIds.length}
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
{turns.length === 0 && <p className={css.empty}>No trajectory events</p>}
{turns.length > 0 && (
<TrajectoryTable
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
)}
</div>
)
}

View File

@@ -28,7 +28,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
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}></p></div>
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>No timing data</p></div>
return (
<>
<TrajectoryStatsHeader useSession={useSession} />

View File

@@ -3,12 +3,16 @@
* own-duration times, in-flight partial/runningCalls, and group descriptions.
*/
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -28,6 +32,7 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
callSchemas?: ConversationSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
@@ -52,8 +57,17 @@ interface LaidCell {
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls, codeDispatches } = input
const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input
const resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
if (startedAt !== null) callStartById.set(result.callId, startedAt)
}
for (const call of runningCalls) {
const startedAt = finiteTime(call.time)
if (startedAt !== null) callStartById.set(call.callId, startedAt)
}
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
let index = 0
let prevAbsTime: number | null = null
@@ -92,14 +106,21 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
opensTurn: node.kind === 'user',
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
startedAt: finiteTime(node.time),
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
@@ -128,7 +149,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node),
outputBlocks: node.content.map(block => sourceBlock(block)),
result: summarizeResult(node),
callId: node.callId,
isError: node.isError,
timeSeconds: durationSeconds(node.time, node.callTime),
startedAt: finiteTime(node.callTime),
},
})
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
@@ -145,7 +173,14 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
const laidList = expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
{ streaming: true },
)
for (const laid of laidList) {
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
else pushMessage(partial.turn, laid)
@@ -165,7 +200,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'tool',
text: summarizeCall(call.name, call.argsRaw),
inputDetail: call.argsRaw,
callId: call.callId,
timeSeconds: null,
startedAt: finiteTime(call.time),
},
})
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
@@ -191,11 +229,28 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
turns.set(1, first)
}
for (const entry of turns.values()) {
for (const laid of entry.message) attachToolSchema(laid, callSchemas)
for (const laid of entry.steps.values()) {
for (const cell of laid) attachToolSchema(cell, callSchemas)
}
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
}
function attachToolSchema(
laid: LaidCell,
callSchemas: ConversationSnapshot['callSchemas'],
): void {
if (laid.callId === undefined || callSchemas === undefined) return
const schema = callSchemas.get(laid.callId)
if (schema === undefined) return
laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
}
function toTurnModel(
turn: number,
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
@@ -267,8 +322,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
}
/** Epoch-ms usable as an absolute time, else null. */
function finiteTime(time: number): number | null {
return Number.isFinite(time) ? time : null
function finiteTime(time: number | null | undefined): number | null {
return typeof time === 'number' && Number.isFinite(time) ? time : null
}
function expandAssistant(
@@ -276,6 +331,7 @@ function expandAssistant(
startIndex: number,
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
const out: LaidCell[] = []
@@ -284,58 +340,155 @@ function expandAssistant(
const streaming = opts?.streaming === true
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
const nodeAbs = streaming ? null : finiteTime(node.time)
let usageAttached = false
const messageText = node.blocks
.filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
.map(block => block.kind === 'text' ? block.text : '')
.join('\n\n')
const thinkingText = node.blocks
.filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
.map(block => block.kind === 'reasoning' ? block.text : '')
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
kind: 'message',
text: messageText !== ''
? summarizeText(messageText)
: thinkingText !== ''
? summarizeText(thinkingText)
: summarizeAssistantActivity(node.blocks),
...(messageText !== '' ? { outputDetail: messageText } : {}),
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
timeSeconds: messageDuration,
startedAt: finiteTime(node.timing?.stepStartTime),
}
attachUsage(message, usage)
message.assistantMetrics = {
timingRecorded: node.timing !== undefined,
stepStartTime: node.timing?.stepStartTime ?? null,
firstTokenTime: node.timing?.firstTokenTime ?? null,
completedTime: streaming ? null : finiteTime(node.time),
usageProvided: usage !== undefined,
outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
}
out.push({ absTime: nodeAbs, cell: message })
for (const block of node.blocks) {
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
if (block.kind === 'reasoning') continue
if (block.kind === 'text') {
if (block.text === '' && streaming) continue
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: summarizeText(block.text),
timeSeconds: messageDuration,
}
if (!usageAttached) {
attachUsage(cell, usage)
usageAttached = usage !== undefined
}
out.push({ absTime: nodeAbs, cell })
continue
}
if (block.kind === 'tool-call') {
const result = results.get(block.callId)
const toolDuration = streaming || result === undefined
? null
: durationSeconds(result.time, result.callTime)
const callAbs = streaming
? null
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
? result.callTime
: nodeAbs)
out.push({
absTime: callAbs,
toolName: block.name,
// Text and reasoning belong to the one Assistant record emitted above.
if (block.kind !== 'tool-call') continue
const result = results.get(block.callId)
const toolDuration = streaming || result === undefined
? null
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
inputDetail: block.argsRaw,
callId: block.callId,
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
timeSeconds: toolDuration,
},
})
}
}
if (out.length === 0 && !streaming) {
// Reasoning-only / empty success still owns provider usage on the Message row.
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
}
attachUsage(cell, usage)
out.push({ absTime: nodeAbs, cell })
...(result !== undefined
? {
outputDetail: detailResult(result),
outputBlocks: result.content.map(block => sourceBlock(block)),
result: summarizeResult(result),
isError: result.isError,
}
: {}),
timeSeconds: toolDuration,
startedAt: callAbs,
},
})
}
return out
}
function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
const tools = new Map<string, number>()
for (const block of blocks) {
if (block.kind !== 'tool-call') continue
tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
}
if (tools.size > 0) {
return 'Tool call only'
}
return ''
}
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
switch (block.kind) {
case 'text': return { type: 'text', content: block.text }
case 'reasoning': return { type: 'thinking', content: block.text }
case 'tool-call': return {
type: 'tool-call',
content: block.argsRaw,
callId: block.callId,
toolName: block.name,
}
case 'other': return sourceBlock(block.block)
}
}
function sourceBlock(value: unknown): TrajectorySourceBlock {
if (typeof value !== 'object' || value === null) {
return { type: 'unknown', content: stringifySourceValue(value) }
}
const block = value as Record<string, unknown>
const type = typeof block.type === 'string' ? block.type : 'unknown'
if (typeof block.text === 'string') {
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
}
const imageSrc = sourceImage(block)
const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
return {
type,
content: imageSrc === undefined ? stringifySourceValue(value) : '',
...(imageSrc !== undefined ? { imageSrc } : {}),
...(imageAlt !== undefined ? { imageAlt } : {}),
}
}
function sourceImage(block: Record<string, unknown>): string | undefined {
if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
for (const candidate of [block.url, block.image_url]) {
if (typeof candidate === 'string') return safeImageSource(candidate)
}
if (typeof block.data === 'string') {
const mediaType = [block.mimeType, block.mediaType, block.media_type]
.find((candidate): candidate is string => typeof candidate === 'string')
?? 'image/png'
return safeImageSource(
block.data.startsWith('data:')
? block.data
: `data:${mediaType};base64,${block.data}`,
)
}
if (typeof block.source !== 'object' || block.source === null) return undefined
const source = block.source as Record<string, unknown>
if (typeof source.url === 'string') return safeImageSource(source.url)
if (typeof source.data !== 'string') return undefined
const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
return safeImageSource(`data:${mediaType};base64,${source.data}`)
}
function safeImageSource(value: string): string | undefined {
if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
return undefined
}
}
function stringifySourceValue(value: unknown): string {
const json = JSON.stringify(value, null, 2)
return json ?? String(value)
}
/**
* Turn that encloses a user/message: next assistant/steering turn, else the
* in-flight partial, else the turn after the last finalized assistant (or 1).
@@ -433,12 +586,27 @@ function expandSubCalls(
cell: {
index: ++index,
kind: 'subtool',
callId: sub.callId,
text: settled
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
: summarizeCall(sub.name, sub.argsRaw),
...(settled
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
: { inputDetail: sub.argsRaw }),
...(settled
? {
outputDetail: detailResult(sub),
outputBlocks: sub.content.map(block => sourceBlock(block)),
result: summarizeResult(sub),
isError: sub.isError,
}
: {}),
// PR3's start/settle pair carries per-sub-call wall time; a running
// (unsettled) or pre-pair log entry shows the em dash.
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
startedAt: settled
? finiteTime(sub.callTime)
: finiteTime(sub.time),
},
})
}
@@ -448,8 +616,7 @@ function expandSubCalls(
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
if (args === '') return name
const clipped = args.length > 72 ? `${args.slice(0, 71)}` : args
return `${name} · ${clipped}`
return `${name} · ${args}`
}
function summarizeResult(node: ToolResultNode): string {
@@ -464,6 +631,27 @@ function summarizeResult(node: ToolResultNode): string {
return node.call?.name ?? node.callId
}
function detailResult(node: ToolResultNode): string {
if (node.isError) {
return node.error === undefined
? 'error'
: `${node.error.name}: ${node.error.code}`
}
const text = node.content
.filter(block => block.type === 'text' && typeof block.text === 'string')
.map(block => block.type === 'text' ? block.text : '')
.join('\n')
if (text !== '') return text
return JSON.stringify(node.content, null, 2)
}
function detailContent(content: readonly { type: string; text?: string }[]): string {
return content
.filter(block => block.type === 'text' && typeof block.text === 'string')
.map(block => block.text ?? '')
.join('\n')
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)

View File

@@ -0,0 +1,81 @@
/** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react'
/** Closed set of trajectory record kinds. */
export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool'
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
export interface AssistantMetricDetail {
timingRecorded: boolean
stepStartTime: number | null
firstTokenTime: number | null
completedTime: number | null
usageProvided: boolean
outputTokens: number | null
}
/** One source content block preserved in model order for the details panel. */
export interface TrajectorySourceBlock {
type: string
content: string
imageSrc?: string
imageAlt?: string
callId?: string
toolName?: string
}
/** Data and optional presentation attributes for one trajectory record. */
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based record index shown as `#N`. */
index: number
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
/** Whether this user record opens a new model turn. */
opensTurn?: boolean
/** Full request/message content for the details panel. */
inputDetail?: string
/** Full assistant/tool result content for the details panel. */
outputDetail?: string
/** Full assistant reasoning content for the details panel. */
thinkingDetail?: string
/** Original message blocks in source order for the details panel. */
sourceBlocks?: readonly TrajectorySourceBlock[]
/** Original tool result blocks in source order for the details panel. */
outputBlocks?: readonly TrajectorySourceBlock[]
/** Call-time model-visible tool schema for the details panel. */
schemaDetail?: string
/** Assistant-only timing and token facts for the details panel. */
assistantMetrics?: AssistantMetricDetail
/** Tool-only result summary paired with the call in the same record. */
result?: string
/** Tool call id used to link message source blocks to tool records. */
callId?: string
/** Tool-only result failure state. */
isError?: boolean
/** Own duration in seconds, or `null` when no duration is known. */
timeSeconds: number | null
/** Unix epoch milliseconds when this operation actually started, when known. */
startedAt?: number | null
/** Message-only prompt token count. */
input?: number
/** Message-only completion token count. */
output?: number
/** Message-only reasoning token count. */
think?: number
/** Whether the legacy standalone cell renders its selection treatment. */
selected?: boolean
}
/**
* Format own-duration for the trailing time column.
* @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise a signed seconds label.
*/
export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s`
return `${rounded.toFixed(1)}s`
}

View File

@@ -1,17 +1,24 @@
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
* cell content width is capped on the turn body (max 880). */
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
.root {
overflow-y: auto;
--dsh-trajectory-toolbar-height: 48px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
min-height: 0;
width: 100%;
box-sizing: border-box;
color: var(--dsw-alias-label-primary);
background: var(--dsw-specific-sidebar-fill);
background: var(--dsw-alias-bg-layer-1);
}
.empty {
padding: 16px;
display: grid;
flex: 1;
margin: 0;
padding: 24px;
place-items: center;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}

View File

@@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
expect(turns[0]?.groups[0]?.description).toBe('3s bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
@@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => {
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage on the fallback Message row when assistant has no text block', () => {
it('keeps usage and a meaningful summary when assistant has no text block', () => {
const nodes = [
{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
@@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
text: '仅推理输出', input: 11, output: 22, think: 3,
})
})
@@ -235,11 +235,12 @@ describe('run_code sub-dispatch cells', () => {
]]]) 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(['tool', 'subtool', 'subtool'])
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])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
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 })
})
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {

View File

@@ -0,0 +1,88 @@
// @vitest-environment jsdom
/** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
afterEach(cleanup)
const TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
description: '1.5s bash×2',
cells: [
{
index: 1,
kind: 'message',
text: 'Checking files',
outputDetail: 'Checking files',
input: 10,
output: 20,
think: 5,
timeSeconds: 1.5,
assistantMetrics: {
timingRecorded: true,
stepStartTime: 1_000,
firstTokenTime: 1_500,
completedTime: 2_500,
usageProvided: true,
outputTokens: 20,
},
},
{
index: 2,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
timeSeconds: null,
},
{
index: 3,
kind: 'tool',
text: 'bash · {"command":"false"}',
inputDetail: '{"command":"false"}',
outputDetail: 'ToolError: non_zero_exit',
result: 'non_zero_exit',
isError: true,
timeSeconds: 0.2,
},
],
}],
}]
describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} collapsed={false} />)
fireEvent.keyDown(screen.getByRole('row', { name: /记录 1ASSISTANT/ }), { key: 'Enter' })
fireEvent.click(screen.getByRole('tab', { name: '计时' }))
expect(screen.getByText('500 ms')).toBeTruthy()
expect(screen.getByText('1.00 s')).toBeTruthy()
expect(screen.getByText('20.0 tok/s')).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()
expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /记录 2TOOL/ }))
expect(screen.getByText('进行中')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /记录 3TOOL/ }))
expect(screen.getByText('失败')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: '输出' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
})
it('retains the ledger header and record count when collapsed', () => {
render(<TrajectoryTable turns={TURNS} collapsed />)
expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
expect(screen.getByText('3 条记录已收起')).toBeTruthy()
expect(screen.queryByRole('row', { name: /记录 1ASSISTANT/ })).toBeNull()
})
})

View File

@@ -4,7 +4,7 @@
* registers trajectory/waterfall into a real SlotsService view ring, tabs
* switch inside ConversationRoot (renderSlot share driven by the same tab
* projection apply uses) without collapsing chat, trajectory renders the
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
* event-ledger chrome (no span stats bar), waterfall keeps in-body stats, and
* fiber disposal removes both tabs. Span derivation edge cases ride along.
*/
import { Context } from 'cordis'
@@ -169,22 +169,48 @@ describe('plugin registration', () => {
})
describe('tab switching in ConversationRoot', () => {
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
it('renders all three tabs, defaults to chat, and switches to the trajectory ledger', async () => {
const b = await bench()
mount(b.slots)
const view = mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull()
expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy()
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
expect(screen.getAllByLabelText('Step 1')).toHaveLength(2)
expect(screen.getByRole('columnheader', { name: '事件' })).toBeTruthy()
expect(screen.getByRole('columnheader', { name: '内容' })).toBeTruthy()
expect(screen.queryByRole('columnheader', { name: 'Tokens' })).toBeNull()
expect(screen.queryByRole('columnheader', { name: '耗时' })).toBeNull()
expect(screen.getByRole('toolbar', { name: '轨迹工具栏' }).textContent).toContain('4 条记录')
fireEvent.click(screen.getByRole('button', { name: '收起记录' }))
expect(screen.getByText('4 条记录已收起')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '展开记录' }))
expect(screen.getByRole('row', { name: /记录 1USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
})
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
const b = await bench()
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
fireEvent.keyDown(screen.getByRole('row', { name: /记录 3TOOL/ }), { key: 'Enter' })
expect(screen.getByRole('complementary', { name: '记录详情' })).toBeTruthy()
expect(screen.getByText('记录 #3')).toBeTruthy()
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
expect(screen.getByText('完成')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: '输入' }))
expect(screen.getByText('这条记录没有输入载荷')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: '输出' }))
expect(screen.getByText('[]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
expect(screen.queryByRole('complementary', { name: '记录详情' })).toBeNull()
})
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
const b = await bench()
mount(b.slots)