feat(ui): complete trajectory request inspection

This commit is contained in:
_Kerman
2026-07-28 09:53:32 +08:00
parent 4137405cdd
commit b3ea0d987e
32 changed files with 1692 additions and 655 deletions

View File

@@ -30,7 +30,8 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
CompactionRequestView, ConversationContext, ConversationContextOriginKind,
ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'

View File

@@ -51,6 +51,7 @@ export interface UserMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** Recorded boundaries used to derive assistant latency and throughput. */
@@ -67,6 +68,7 @@ export interface AssistantTiming {
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
@@ -108,6 +110,7 @@ export interface SteeringMessageNode {
turn: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A context/system injection surfaced in the flow. */
@@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot {
tools: readonly ToolSchema[]
}
/** One system-prompt/tool-catalog state that became effective in the request timeline. */
export interface ConversationPromptChange {
/** Sequence of the request/header event that introduced this state. */
seq: number
/** Unix epoch ms from the request/header event. */
time: number
/** How the model-visible system configuration differs from the prior recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** Complete state effective from this event onward. */
prompt: ConversationPromptSnapshot
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
@@ -259,6 +276,46 @@ export interface ConversationContext {
nodes: readonly ConversationNode[]
}
/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
export interface CompactionRequestView {
startSeq: number
turn: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
summarySeq?: number
replacementSeq?: number
summary?: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
}
/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
export interface ModelRequestView {
/** Sequence of the step/start event that opened this attempt. */
startSeq: number
turn: number
step: number
/** Unix epoch ms from step/start. */
startedAt: number
/** Assistant completion time, or the failed step/end time when no response completed. */
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Assistant/message sequence when this attempt completed successfully. */
resultSeq?: number
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Retry ordinal scheduled after this failed attempt. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
@@ -272,6 +329,12 @@ export interface ConversationSnapshot {
nodes: readonly ConversationNode[]
/** Append-only context generations split at every model-surface replacement. */
contexts?: readonly ConversationContext[]
/** Auxiliary compaction requests, including those without an assistant/message surface node. */
compactionRequests?: readonly CompactionRequestView[]
/** Ordinary provider requests, including failed attempts that produced no assistant message. */
requestAttempts?: readonly ModelRequestView[]
/** System-prompt/tool-catalog changes in request order. */
promptChanges?: readonly ConversationPromptChange[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null

View File

@@ -59,6 +59,7 @@ function materializeNode(
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'assistant/message':
return {
@@ -76,6 +77,7 @@ function materializeNode(
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))

View File

@@ -12,8 +12,9 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode,
ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot,
ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private callSchemas = new Map<string, ToolSchema>()
private callSchemasRev = 0
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
private promptChangesRev = 0
private promptChangesCache: {
rev: number
value: readonly ConversationPromptChange[]
} | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
switch (event.type) {
case 'request/header': {
this.promptChangesRev++
this.activeToolSchemas = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
)
@@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.activeToolSchemas = new Map()
this.callSchemas = new Map()
this.callSchemasRev++
this.promptChangesRev++
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. */
@@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
if (
this.promptChangesCache === null
|| this.promptChangesCache.rev !== this.promptChangesRev
) {
this.promptChangesCache = {
rev: this.promptChangesRev,
value: derivePromptChanges(this.events),
}
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
contexts,
compactionRequests: deriveCompactionRequests(this.events),
requestAttempts: deriveModelRequests(this.events),
promptChanges: this.promptChangesCache.value,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
@@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
function modelRequestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
/** Project every durable step into one provider request, retaining failed retry attempts. */
function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
const requests: ModelRequestView[] = []
const byStep = new Map<string, number>()
let activeStep: string | undefined
let activeConfig: ConversationPromptSnapshot['config']
const update = (key: string, change: Partial<ModelRequestView>): void => {
const index = byStep.get(key)
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'request/header') {
activeConfig = sourceEvent.data.header.config
if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
continue
}
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = modelRequestKey(turn, step)
byStep.set(key, requests.length)
requests.push({
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'assistant/message') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
update(key, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.provenance.provider,
model: sourceEvent.data.provenance.model,
},
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = byStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request !== undefined && request.status === 'running') {
requests[index] = {
...request,
completedAt: sourceEvent.time,
status: 'error',
}
}
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(modelRequestKey(event.data.turn, event.data.step), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
const reason = sourceEvent.data.reason
update(modelRequestKey(sourceEvent.data.turn, reason.step), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
}
return requests
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
}
/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
const requests: CompactionRequestView[] = []
let active: CompactionRequestView | undefined
for (const sourceEvent of events) {
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
active = {
startSeq: event.seq,
turn: event.data.turn,
startedAt: event.time,
completedAt: null,
status: 'running',
}
continue
}
if (type === 'compact/summary' && active !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
active = {
...active,
summarySeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
}
continue
}
if (
sourceEvent.type === 'user/message'
&& active?.summarySeq !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
active = { ...active, replacementSeq: sourceEvent.seq }
continue
}
if (type !== 'compact/end' || active === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
active = {
...active,
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
}
requests.push(active)
active = undefined
}
if (active !== undefined) requests.push(active)
return requests
}
/** Project request headers into model-visible system/tool changes only. */
function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
const changes: ConversationPromptChange[] = []
let previous: ConversationPromptSnapshot | undefined
for (const event of events) {
if (event.type !== 'request/header') continue
const prompt: ConversationPromptSnapshot = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous === undefined || systemChanged || toolsChanged) {
changes.push({
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
prompt,
...(previous === undefined ? {} : { previous }),
})
}
previous = prompt
}
return changes
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -6,6 +6,7 @@
* lightningcss inside the bundle: importing `x.module.css` yields the
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
* tag at factory execution (the loader removes plugin-owned tags on unload).
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
@@ -127,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,

View File

@@ -49,9 +49,7 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. */
.handle {
position: absolute;
z-index: 6;
@@ -75,36 +73,3 @@
transition: none;
}
}
.handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 32px;
border-radius: 10px;
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}

View File

@@ -49,7 +49,7 @@
}
.expandedTopLevelContainer {
padding: 0 0 0 calc(2ch - 12px);
padding: 0;
}
.row.topLevelBracket {
@@ -59,7 +59,7 @@
.children {
margin: 0;
padding: 0 0 0 4px;
padding: 0;
list-style: none;
}
@@ -69,7 +69,7 @@
min-width: 100%;
min-height: 16px;
margin: 0;
padding: 0 0 0 12px;
padding: 0 0 0 10px;
list-style: none;
}
@@ -178,7 +178,7 @@
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
box-sizing: border-box;
width: 8px;
height: 16px;
@@ -196,7 +196,7 @@
border-left: 6px solid currentColor;
content: '';
transform: scale(0.75);
transform-origin: center;
transform-origin: 33.333% center;
}
.collapseIcon::before {

View File

@@ -66,6 +66,17 @@
font: inherit;
}
.noLanguage .bannerWrap {
position: absolute;
right: 0;
background: transparent;
}
.noLanguage .banner {
padding-left: 8px;
background: transparent;
}
.block :where(pre) {
font: var(--dsl-code-block-content-font);
padding: 16px;
@@ -76,6 +87,10 @@
background: var(--dsw-alias-markdown-code-block);
}
.noLanguage :where(pre) {
padding-right: 72px;
}
/* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important;

View File

@@ -52,6 +52,7 @@ async function writeClipboard(text: string): Promise<boolean> {
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const hasLanguage = lang !== undefined && lang !== ''
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
@@ -80,7 +81,10 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div
ref={rootRef}
className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>

View File

@@ -1,8 +1,7 @@
/**
* 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.
* Parsing shares the renderer's GFM grammar; raw HTML remains literal, links
* keep their labels, images keep alt text, and code keeps its source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
@@ -37,6 +36,7 @@ function inlineText(node: MarkdownNode): string {
case 'break':
return '\n'
case 'html':
return node.value ?? ''
case 'thematicBreak':
case 'definition':
return ''
@@ -70,6 +70,7 @@ function blockText(node: MarkdownNode): string {
case 'tableCell':
return compactInline(inlineText(node))
case 'html':
return node.value?.trim() ?? ''
case 'thematicBreak':
case 'definition':
return ''

View File

@@ -33,6 +33,9 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"diff": "^9.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",

View File

@@ -1,116 +0,0 @@
.root {
display: flex;
flex: none;
width: 218px;
min-width: 176px;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
border-right: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
}
.header {
display: flex;
flex: none;
height: 34px;
align-items: center;
box-sizing: border-box;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
.list {
min-height: 0;
padding: 4px;
overflow: auto;
}
.item {
display: flex;
width: 100%;
min-width: 0;
min-height: 42px;
align-items: center;
box-sizing: border-box;
padding: 4px 7px;
gap: 7px;
border: 0;
border-radius: 4px;
color: var(--dsw-alias-label-primary);
background: transparent;
cursor: pointer;
text-align: left;
}
.item:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.item:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.itemSelected {
background: var(--dsw-alias-interactive-bg-active);
box-shadow: inset 2px 0 var(--dsw-alias-state-business-primary);
}
.icon {
flex: none;
color: var(--dsw-alias-label-caption);
}
.itemSelected .icon {
color: var(--dsw-alias-state-business-primary);
}
.itemBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.itemTitle,
.itemMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemTitle {
font: var(--dsw-font-xs-13);
}
.itemMeta {
color: var(--dsw-alias-label-tertiary);
font: 11px/16px var(--ds-font-family-code);
}
.current,
.frozen {
flex: none;
align-self: flex-start;
padding-top: 1px;
font: 10px/16px var(--ds-font-family-code);
user-select: none;
}
.current {
color: var(--dsw-alias-state-business-primary);
}
.frozen {
color: var(--dsw-alias-label-caption);
}
@media (max-width: 820px) {
.root {
width: 184px;
}
}

View File

@@ -1,84 +0,0 @@
/** Context-generation selector for a trajectory session. */
import { IconBranchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
ConversationContext, ConversationContextOriginKind,
} from '@deepseek-ai/dsh-client-runtime/client'
import css from './ContextsPanel.module.css'
export interface ContextsPanelProps {
contexts: readonly ConversationContext[]
selectedId: number
currentId: number
onSelect(id: number): void
}
function formatTime(timestamp: number | undefined): string | undefined {
if (timestamp === undefined || !Number.isFinite(timestamp)) return
const date = new Date(timestamp)
const two = (value: number) => String(value).padStart(2, '0')
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`
}
function originLabel(origin: ConversationContextOriginKind | undefined): string {
if (origin === 'compaction') return 'Compaction'
if (origin === 'rewind') return 'Rewind'
if (origin === 'rewrite') return 'Context rewrite'
return 'Initial context'
}
/** Human-facing context title without exposing internal generation ids. */
export function contextLabel(context: ConversationContext): string {
const label = originLabel(context.origin)
const time = formatTime(context.createdAt)
return time === undefined ? label : `${label} · ${time}`
}
/**
* Render every append-only context generation in creation order.
* @param props - Contexts and the selected/current identities.
* @returns The context navigation panel.
*/
export function ContextsPanel({
contexts,
selectedId,
currentId,
onSelect,
}: ContextsPanelProps) {
return (
<aside className={css.root} aria-label="Contexts">
<div className={css.header}>Contexts</div>
<div className={css.list}>
{contexts.map((context) => {
const selected = context.id === selectedId
const current = context.id === currentId
const parent = context.parentId === undefined
? undefined
: contexts.find(candidate => candidate.id === context.parentId)
return (
<button
key={context.id}
type="button"
className={selected ? `${css.item} ${css.itemSelected}` : css.item}
aria-current={selected ? 'true' : undefined}
onClick={() => { onSelect(context.id) }}
>
<IconBranchOutline16 className={css.icon} size={14} />
<span className={css.itemBody}>
<span className={css.itemTitle}>{contextLabel(context)}</span>
<span className={css.itemMeta}>
{context.origin === undefined
? 'Session origin'
: `from ${parent === undefined ? 'previous context' : originLabel(parent.origin)}`}
</span>
</span>
<span className={current ? css.current : css.frozen}>
{current ? 'Current' : 'Frozen'}
</span>
</button>
)
})}
</div>
</aside>
)
}

View File

@@ -46,6 +46,11 @@
white-space: nowrap;
}
.tagSystem {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.tagUser {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-state-success-tertiary);

View File

@@ -16,6 +16,7 @@ export type {
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'System',
user: 'User',
context: 'Context',
message: 'Message',
@@ -24,6 +25,7 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
system: css.tagSystem!,
user: css.tagUser!,
context: css.tagContext!,
message: css.tagMessage!,
@@ -41,6 +43,8 @@ export function TrajectoryCell({
kind,
text,
inputDetail: _inputDetail,
promptDetail: _promptDetail,
previousPromptDetail: _previousPromptDetail,
outputDetail: _outputDetail,
thinkingDetail: _thinkingDetail,
sourceBlocks: _sourceBlocks,

View File

@@ -18,7 +18,7 @@
.table {
--trajectory-turn-accent: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 22%,
var(--dsw-static-blue-500) 22%,
var(--dsw-alias-bg-layer-1)
);
@@ -28,7 +28,7 @@
table-layout: fixed;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-xs-13);
font: var(--dsw-font-xxs-12);
}
.eventColumn {
@@ -50,7 +50,7 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
background: var(--dsw-specific-sidebar-fill);
font: var(--dsw-font-xs-13);
font: var(--dsw-font-xxs-12);
font-weight: 500;
text-align: left;
text-overflow: ellipsis;
@@ -83,6 +83,22 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.table tbody tr[data-request-only='true']:hover {
background: transparent;
}
.table tbody tr[data-request-only='true'] td {
height: 1px;
padding-top: 0;
padding-bottom: 0;
border-bottom: 0;
}
.table tbody tr[data-request-only='true'] .turnRail {
top: -15px;
bottom: 0;
}
.table tbody tr:not([data-collapsed-summary]):focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
}
@@ -95,7 +111,7 @@
position: absolute;
z-index: 6;
top: -8px;
left: 6px;
left: 2px;
width: 16px;
height: 16px;
padding: 0;
@@ -106,10 +122,10 @@
.requestBoundaryControl::before {
position: absolute;
top: 6px;
left: 6px;
width: 4px;
height: 4px;
top: 5.5px;
left: 5.5px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--dsw-alias-label-caption);
box-shadow:
@@ -118,7 +134,7 @@
content: '';
transition:
background 120ms var(--ds-ease-in-out),
transform 120ms var(--ds-ease-in-out);
box-shadow 120ms var(--ds-ease-in-out);
}
.requestBoundaryControl::after {
@@ -127,9 +143,11 @@
left: 17px;
width: max-content;
padding: 0 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 2px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
content: attr(data-label);
font: 9px/12px var(--ds-font-family-code);
opacity: 0;
@@ -143,10 +161,19 @@
}
.requestBoundaryControl:hover::before,
.requestBoundaryControl:focus-visible::before,
.requestBoundaryControl[aria-pressed='true']::before {
background: var(--dsw-alias-label-primary);
transform: scale(1.25);
.requestBoundaryControl:focus-visible::before {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControlActive::before,
.requestBoundaryControlActive:hover::before,
.requestBoundaryControlActive:focus-visible::before {
background: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%,
var(--dsw-alias-bg-layer-1)
);
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControl:hover::after,
@@ -219,14 +246,17 @@
}
.turnLabel {
position: absolute;
z-index: 3;
top: 0;
left: 0;
display: inline-flex;
flex: none;
align-items: center;
box-sizing: border-box;
width: max-content;
margin-left: 4px;
padding: 1px 2px;
border-radius: 2px;
padding: 1px 5px;
border-radius: 0 0 2px;
color: var(--dsw-alias-label-tertiary);
background: var(--dsw-alias-bg-module-platform);
font: 8px/10px var(--ds-font-family-code);
@@ -241,11 +271,7 @@
var(--dsw-static-blue-500) 55%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-static-blue-500) 10%,
var(--dsw-alias-bg-layer-1)
);
background: var(--trajectory-turn-accent);
}
.eventInner {
@@ -260,15 +286,8 @@
display: flex;
flex: none;
align-items: flex-end;
width: 76px;
}
.kindSlotLeft {
justify-content: flex-start;
}
.kindSlotRight {
justify-content: flex-end;
width: 76px;
}
.content {
@@ -316,6 +335,80 @@
background: var(--dsw-alias-state-success-tertiary);
}
.compacted {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.compactedSummary {
margin-top: 12px;
padding: 0 0 14px;
font: var(--dsw-font-xs-13);
}
.promptDiffSections {
display: flex;
max-height: 100%;
flex-direction: column;
padding: 10px 14px 14px;
gap: 14px;
overflow: auto;
}
.promptDiffSection {
min-width: 0;
}
.promptDiffTitle {
margin: 0 0 6px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
.promptDiff {
margin: 0;
overflow: auto;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: 11px/17px var(--ds-font-family-code);
white-space: pre;
}
.promptDiff span {
display: block;
min-width: max-content;
padding: 0 6px;
}
.promptDiffLinemeta {
color: var(--dsw-alias-label-caption);
background: var(--dsw-alias-bg-module-platform);
user-select: none;
}
.promptDiffLinecontext {
color: var(--dsw-alias-label-secondary);
}
.promptDiffLineadded {
color: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 72%,
var(--dsw-alias-label-primary)
);
background: var(--dsw-alias-state-success-tertiary);
}
.promptDiffLineremoved {
color: var(--dsw-alias-state-error-primary);
background: color-mix(
in srgb,
var(--dsw-alias-state-error-primary) 12%,
var(--dsw-alias-bg-layer-1)
);
}
.assistantVioletBright {
color: color-mix(
in srgb,
@@ -359,8 +452,9 @@
white-space: nowrap;
}
.table tbody tr[data-collapsed-summary] td {
height: 24px;
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
}
.table tbody tr[data-collapsed-summary] {
@@ -383,7 +477,7 @@
min-width: 0;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
line-height: 16px;
}
.collapsedTurnEllipsis {
@@ -425,7 +519,7 @@
.toolCallNameTypeface {
color: var(--dsw-alias-label-primary);
font: 400 13px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
font: 400 12px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
'Microsoft YaHei';
}
@@ -454,6 +548,10 @@
color: var(--dsw-alias-label-secondary);
}
.noOutputText {
color: var(--dsw-alias-label-caption);
}
.arrow {
flex: none;
margin-right: 8px;
@@ -492,38 +590,6 @@
user-select: none;
}
.detailsResizeHandle::after {
position: absolute;
top: 50%;
left: 50%;
box-sizing: border-box;
width: 12px;
height: 32px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-1);
content: '';
opacity: 0;
transform: translate(-50%, -50%);
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.details:hover .detailsResizeHandle::after,
.detailsResizeHandle:hover::after,
.detailsResizeHandle:active::after,
.detailsResizeHandle:focus-visible::after {
opacity: 1;
}
.detailsResizeHandle:hover::after,
.detailsResizeHandle:active::after,
.detailsResizeHandle:focus-visible::after {
border-color: var(--dsw-alias-border-l3);
background: var(--dsw-alias-bg-module-platform);
}
.detailsResizeHandle:focus-visible {
outline: none;
}
@@ -645,6 +711,28 @@
overflow: auto;
}
.detailBodySummary {
display: flex;
box-sizing: border-box;
flex-direction: column;
padding-bottom: 12px;
overflow: hidden;
}
.detailBodySummary > .overview {
flex: none;
}
.detailBodySummary > .compactedSummary {
flex: 1;
min-height: 0;
overflow: auto;
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
.overview {
margin: 0;
padding: 8px 0;
@@ -733,6 +821,20 @@
white-space: nowrap;
}
.timestampToggle {
all: unset;
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}
.timestampToggle:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.tokenEquation {
display: inline-flex;
align-items: baseline;
@@ -744,15 +846,34 @@
}
.overviewSections {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
border-top: 0;
}
.overviewSection {
display: flex;
flex: 0 1 auto;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.overviewSection:has(> .overviewPreview > .overview),
.overviewSection:has(> .overviewPreview > .noPayload) {
flex-shrink: 0;
}
.overviewSection + .overviewSection {
padding-top: 8px;
}
.overviewHeading {
display: flex;
flex: none;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
@@ -801,7 +922,8 @@
}
.overviewPreview {
max-height: 180px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
background: var(--dsw-alias-bg-layer-1);
@@ -865,6 +987,42 @@
font: var(--dsw-font-xs-13);
}
.markdownPreview > div > :first-child {
margin-top: 0;
}
.markdownPreview > div > :last-child {
margin-bottom: 0;
}
.markdownPreview > div :where(h1, h2, h3) {
margin: 12px 0 6px;
}
.markdownPreview > div :where(h4, h5, h6) {
margin: 10px 0 5px;
}
.markdownPreview > div :where(p, ul, ol) {
margin: 8px 0;
}
.markdownPreview > div li:not(:first-child) {
margin-top: 3px;
}
.markdownPreview > div blockquote {
margin-top: 8px;
}
.markdownPreview > div hr {
margin: 14px 0;
}
.markdownPreview > div > :global(.md-code-block) {
margin: 10px 0;
}
.assistantContent .markdownPayload,
.assistantContent .payload {
min-height: 0;
@@ -1130,11 +1288,12 @@
all: unset;
display: flex;
box-sizing: border-box;
width: 100%;
width: calc(100% + 4px);
min-width: 0;
height: 19px;
align-items: center;
padding: 0 4px 0 0;
margin-left: -4px;
padding: 0 4px;
border-radius: 3px;
cursor: pointer;
}

View File

@@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
AssistantRequestConfig, ConversationPromptSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -16,8 +17,10 @@ import type { TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
user: 'USER',
context: 'CONTEXT',
compacted: 'COMPACTED',
message: 'ASSISTANT',
tool: 'TOOL',
subtool: 'SUBTOOL',
@@ -40,12 +43,14 @@ type DetailTab =
| 'overview'
| 'rendered'
| 'source'
| 'origin'
| 'input'
| 'output'
| 'schema'
| 'options'
| 'usage'
| 'timing'
| 'diff'
type RecordState = 'complete' | 'running' | 'error'
interface DetailTabItem {
@@ -86,11 +91,14 @@ const TOOL_REQUEST_MIN_WIDTH = 180
const TOOL_REQUEST_MAX_WIDTH = 480
const DEFAULT_TOOL_REQUEST_SHARE = 0.36
const DEFAULT_TOOL_REQUEST_OFFSET = 56
const SYSTEM_PROMPT_INDEX = 0
const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
{ id: 'system-prompt', label: 'System Prompt' },
{ id: 'tools', label: 'Tools' },
]
const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [
{ id: 'diff', label: 'Diff' },
...SYSTEM_PROMPT_TABS,
]
const REQUEST_TABS: readonly DetailTabItem[] = [
{ id: 'overview', label: 'Summary' },
{ id: 'options', label: 'Options' },
@@ -125,19 +133,40 @@ function formatDurationMs(milliseconds: number): string {
return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
}
function formatStartedAt(timestamp: number | null): { label: string; title?: string } {
if (timestamp === null || !Number.isFinite(timestamp)) return { label: 'Not available' }
function formatStartedAt(timestamp: number | null): string {
if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available'
const date = new Date(timestamp)
const two = (value: number) => String(value).padStart(2, '0')
const three = (value: number) => String(value).padStart(3, '0')
const time = `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${three(date.getMilliseconds())}`
const day = `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
return { label: time, title: `${day} ${time}` }
return `${day} ${time}`
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const formatted = formatStartedAt(timestamp)
return <dd title={formatted.title}>{formatted.label}</dd>
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
return (
<dd>
<button
type="button"
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
setShowUnix(current => !current)
}}
>
{showUnix ? (timestamp / 1_000).toFixed(3) : formatStartedAt(timestamp)}
</button>
</dd>
)
}
function totalTime(metrics: AssistantMetricDetail): string {
@@ -184,8 +213,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
/** Props for the trajectory ledger. */
export interface TrajectoryTableProps {
/** Latest model request header in force for the selected context. */
prompt?: ConversationPromptSnapshot
/** Session-global request numbers for the request groups visible in this context. */
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
@@ -200,11 +227,23 @@ export interface TrajectoryTableProps {
onToggleAssistant(index: number): void
}
/** One context-local request identity paired with its session-global number. */
/** One request identity paired with its session-global number. */
export interface TrajectoryRequestNumber {
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
seq?: number
turn: number
step: number
group: string
number: number
purpose?: 'compaction'
status?: 'complete' | 'running' | 'error'
startedAt?: number
completedAt?: number | null
error?: string
retry?: number
maxRetries?: number
retryDelayMs?: number
resultSeq?: number
provider?: string
model?: string
requestConfig?: AssistantRequestConfig
@@ -226,7 +265,10 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
let firstInTurn = true
const records = turn.groups.flatMap((group) => {
return group.cells.map((cell, index) => {
const turnStart = firstInTurn && index === 0
const turnStart = firstInTurn
&& cell.requestOnly !== true
&& cell.kind !== 'system'
&& cell.kind !== 'compacted'
if (turnStart) firstInTurn = false
return {
turn: turn.turn,
@@ -260,7 +302,7 @@ function indexRequestNumbers(
): ReadonlyMap<string, number> {
const numbers = new Map<string, number>()
for (const request of sessionNumbers ?? []) {
numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number)
numbers.set(requestKey(request.turn, request.group), request.number)
}
let next = Math.max(0, ...numbers.values()) + 1
const boundaries = records
@@ -274,14 +316,6 @@ function indexRequestNumbers(
}
function summarizeTurn(records: readonly TableRecord[]): string {
const userText = records
.filter(record => record.cell.kind === 'user')
.map(record => recordDisplayText(record.cell))
.find(text => text !== '')
const assistantText = records
.filter(record => record.cell.kind === 'message')
.map(record => recordDisplayText(record.cell))
.find(text => text !== '')
const steps = new Set(
records
.map(record => record.group)
@@ -290,14 +324,10 @@ function summarizeTurn(records: readonly TableRecord[]): string {
const toolCalls = records.filter(record =>
record.cell.kind === 'tool' || record.cell.kind === 'subtool',
).length
const parts: string[] = []
const message = userText ?? assistantText
if (message !== undefined) parts.push(message)
parts.push(
return [
`${steps} ${steps === 1 ? 'step' : 'steps'}`,
`${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`,
)
return parts.join(' · ')
].join(' · ')
}
function collapseTurnRecords(
@@ -314,8 +344,11 @@ function collapseTurnRecords(
return records.flatMap((record) => {
if (!collapsedTurns.has(record.turn)) return [record]
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
if (turnRecords.length <= 1) return [record]
if (!record.turnStart) return []
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
const contentRecords = turnRecords.filter(candidate =>
candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system')
if (contentRecords.length <= 1) return [record]
if (record.cell.index !== contentRecords[0]?.cell.index) return []
return [
{ ...record, turnEnd: false },
{
@@ -323,7 +356,7 @@ function collapseTurnRecords(
groupStart: false,
turnStart: false,
turnEnd: true,
collapsedSummary: summarizeTurn(turnRecords.slice(1)),
collapsedSummary: summarizeTurn(contentRecords.slice(1)),
collapsedSummaryKind: 'turn',
},
]
@@ -395,6 +428,7 @@ function collapseAssistantRecords(
function stateOf(record: TableRecord): RecordState {
if (record.cell.isError) return 'error'
if (record.cell.kind === 'compacted' && record.cell.timeSeconds === null) return 'running'
if (
(record.cell.kind === 'tool' || record.cell.kind === 'subtool')
&& record.cell.outputDetail === undefined
@@ -437,6 +471,9 @@ function inputTotal(usage: TrajectoryUsage): number | undefined {
function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
if (usage === undefined) return <p className={css.noPayload}>Usage not reported</p>
const totalInput = inputTotal(usage)
const otherOutput = usage.output !== undefined && usage.reasoning !== undefined
? usage.output - usage.reasoning
: undefined
return (
<dl className={css.overview}>
{totalInput !== undefined && (
@@ -469,6 +506,12 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
<dd>{usage.reasoning} tok</dd>
</div>
)}
{otherOutput !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Content</dt>
<dd>{otherOutput} tok</dd>
</div>
)}
</dl>
)
}
@@ -513,6 +556,46 @@ function RequestOptions({
)
}
function messageOriginLabel(source: unknown): string {
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
return 'Unknown'
}
const kind = Reflect.get(source, 'kind')
if (kind === 'user') return 'User'
if (kind === 'plugin') {
const plugin = Reflect.get(source, 'plugin')
return typeof plugin === 'string' && plugin !== ''
? `Plugin · ${plugin}`
: 'Plugin'
}
if (kind === 'goal') {
const round = Reflect.get(source, 'round')
return typeof round === 'number' && round > 0
? `Goal · Round ${round}`
: 'Goal'
}
if (typeof kind !== 'string' || kind === '') return 'Unknown'
return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
}
function MessageOrigin({ record }: { record: TableRecord }) {
const source = record.cell.messageSource
if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
const sourceRoot = typeof source === 'object' && source !== null
? source
: { value: source }
const data = record.cell.messageMeta === undefined
? sourceRoot
: { source, meta: record.cell.messageMeta }
return (
<JsonTree
data={data}
label="Message origin JSON"
className={css.jsonPayload!}
/>
)
}
function isMarkdownRecord(record: TableRecord): boolean {
return record.cell.kind === 'user'
|| record.cell.kind === 'context'
@@ -557,16 +640,32 @@ function markdownSource(record: TableRecord): string | undefined {
if (record.cell.kind === 'user' || record.cell.kind === 'context') {
return record.cell.inputDetail
}
if (record.cell.kind === 'message') return record.cell.outputDetail
if (record.cell.kind === 'message' || record.cell.kind === 'compacted') {
return record.cell.outputDetail
}
return undefined
}
function detailTabs(record: TableRecord): readonly DetailTabItem[] {
if (record.cell.kind === 'system') {
return record.cell.previousPromptDetail === undefined
? SYSTEM_PROMPT_TABS
: SYSTEM_UPDATE_TABS
}
if (record.cell.kind === 'compacted') {
return [
{ id: 'overview', label: 'Summary' },
{ id: 'source', label: 'Raw Output' },
]
}
if (isMarkdownRecord(record)) {
return [
{ id: 'overview', label: 'Summary' },
{ id: 'rendered', label: 'Preview' },
{ id: 'source', label: 'Source' },
...(record.cell.messageSource === undefined
? []
: [{ id: 'origin', label: 'Origin' } as const]),
]
}
return [
@@ -586,8 +685,7 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
? cell.outputDetail ?? cell.thinkingDetail
: undefined
if (!markdown) return cell.text
const plainText = extractMarkdownPlainText(markdown)
return plainText.replace(/\s+/g, ' ').trim()
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
}
function toolCallTextParts(
@@ -824,6 +922,83 @@ function ToolCatalog({ tools }: { tools: ConversationPromptSnapshot['tools'] })
)
}
interface PromptDiffLine {
kind: 'meta' | 'context' | 'added' | 'removed'
text: string
}
function promptDiffLines(before: string, after: string): readonly PromptDiffLine[] {
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: 3 })
return patch.hunks.flatMap((hunk, hunkIndex) => [
...(hunkIndex === 0 ? [] : [{ kind: 'meta' as const, text: '' }]),
{
kind: 'meta' as const,
text: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
},
...hunk.lines.flatMap((line): PromptDiffLine[] => {
if (line.startsWith('\\')) return []
if (line.startsWith('+')) return [{ kind: 'added', text: line }]
if (line.startsWith('-')) return [{ kind: 'removed', text: line }]
return [{ kind: 'context', text: line }]
}),
])
}
function PromptDiffSection({
title,
before,
after,
}: {
title: string
before: string
after: string
}) {
const lines = promptDiffLines(before, after)
if (lines.length === 0) return null
return (
<section className={css.promptDiffSection}>
<h3 className={css.promptDiffTitle}>{title}</h3>
<pre className={css.promptDiff}>
{lines.map((line, index) => (
<span className={css[`promptDiffLine${line.kind}`]} key={index}>
{line.text || ' '}
{'\n'}
</span>
))}
</pre>
</section>
)
}
function SystemPromptDiff({
before,
after,
}: {
before: ConversationPromptSnapshot
after: ConversationPromptSnapshot
}) {
const toolsBefore = JSON.stringify(before.tools, null, 2)
const toolsAfter = JSON.stringify(after.tools, null, 2)
return (
<div className={css.promptDiffSections}>
{before.system !== after.system && (
<PromptDiffSection
title="System Prompt"
before={before.system}
after={after.system}
/>
)}
{toolsBefore !== toolsAfter && (
<PromptDiffSection
title="Tools"
before={toolsBefore}
after={toolsAfter}
/>
)}
</div>
)
}
function ToolOutputBlocks({
blocks,
preview,
@@ -862,7 +1037,7 @@ function MarkdownRecordContent({
if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
}
if (record.cell.kind === 'message' && record.cell.thinkingDetail) {
if (record.cell.thinkingDetail) {
if (!rendered) {
const source = [
record.cell.thinkingDetail,
@@ -961,11 +1136,28 @@ function RecordTiming({ record }: { record: TableRecord }) {
function RequestTiming({
assistant,
anchor,
request,
}: {
assistant: TableRecord | undefined
anchor: TableRecord | undefined
request?: TrajectoryRequestNumber
}) {
if (assistant !== undefined) return <RecordTiming record={assistant} />
if (request?.startedAt !== undefined) {
const duration = request.completedAt === null || request.completedAt === undefined
? null
: Math.max(0, (request.completedAt - request.startedAt) / 1000)
return (
<dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(duration)}</dd></div>
<div>
<dt>Timing source</dt>
<dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
</div>
</dl>
)
}
return (
<dl className={css.overview}>
<div>
@@ -992,7 +1184,11 @@ function RecordPayload({
: 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p>
if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) {
if (
direction === 'output'
&& record.cell.outputBlocks?.some(block =>
block.imageSrc !== undefined || block.content !== '') === true
) {
return (
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
@@ -1029,6 +1225,7 @@ function RecordPayload({
css.payload,
preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined,
value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{value}
@@ -1141,7 +1338,6 @@ function OverviewSection({
* @returns The ledger and an optional local record inspector.
*/
export function TrajectoryTable({
prompt,
requestNumbers: sessionRequestNumbers,
turns,
collapsedTurns,
@@ -1161,13 +1357,14 @@ export function TrajectoryTable({
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
const systemPromptPreview = prompt === undefined
? 'Request header not recorded'
: prompt.system === ''
? 'No system prompt'
: extractMarkdownPlainText(prompt.system).replace(/\s+/g, ' ').trim()
const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
const selectedPreviousPrompt = selected?.cell.kind === 'system'
? selected.cell.previousPromptDetail
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestRecords = selectedRequest === null
? []
@@ -1179,23 +1376,27 @@ export function TrajectoryTable({
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
? 'running'
: selectedRequestAssistant === undefined
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
: selectedRequestInfo?.status
?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
? 'running'
: 'complete'
: selectedRequestAssistant === undefined
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
? 'running'
: 'complete')
const selectedRequestToolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'tool',
).length
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool',
).length
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
? selectedRequestAssistant
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
selectedRequestAssistant === undefined
? undefined
@@ -1223,9 +1424,7 @@ export function TrajectoryTable({
const activeTurn = selectedRequest?.turn ?? selected?.turn
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: promptSelected
? SYSTEM_PROMPT_TABS
: selected === undefined ? [] : detailTabs(selected)
: selected === undefined ? [] : detailTabs(selected)
const selectedParents: ParentRecords = selected === undefined
? {}
: parentRecords(allRecords, selected)
@@ -1260,15 +1459,10 @@ export function TrajectoryTable({
setSelectedRequest(null)
setSelectedIndex(index)
if (record === undefined) return
const available = new Set(detailTabs(record).map(tab => tab.id))
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? 'overview')
}
const selectSystemPrompt = () => {
setSelectedRequest(null)
setSelectedIndex(SYSTEM_PROMPT_INDEX)
activateTab('system-prompt')
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
}
const selectRequest = (
@@ -1311,36 +1505,6 @@ export function TrajectoryTable({
<col className={css.contentColumn} />
</colgroup>
<tbody>
<tr
tabIndex={0}
aria-label="System prompt and tool catalog"
aria-selected={promptSelected}
data-kind="system"
data-selected={promptSelected || undefined}
onClick={selectSystemPrompt}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
selectSystemPrompt()
}}
>
<td className={css.event}>
{promptSelected && <span className={css.selectionRail} aria-hidden="true" />}
<div className={css.eventInner}>
<span className={`${css.kindSlot} ${css.kindSlotLeft}`}>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
</span>
</div>
</td>
<td className={css.content}>
<span
className={css.contentText}
title={prompt?.system || systemPromptPreview}
>
{systemPromptPreview}
</span>
</td>
</tr>
{records.map((record) => {
const displayText = recordDisplayText(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
@@ -1348,20 +1512,38 @@ export function TrajectoryTable({
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
&& !isCollapsedSummary
&& !collapsedTurns.has(record.turn)
? requestNumbers.get(requestKey(record.turn, record.group))
: undefined
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.number === request
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
tabIndex={0}
tabIndex={isRequestOnly ? -1 : 0}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index}
: isRequestOnly
? `Request ${request ?? ''}, compaction`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
@@ -1369,14 +1551,16 @@ export function TrajectoryTable({
data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
onClick={isCollapsedSummary
onClick={isRequestOnly
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary) return
if (isCollapsedSummary || isRequestOnly) return
if (collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
@@ -1391,11 +1575,15 @@ export function TrajectoryTable({
return
}
if (!record.turnStart) return
if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
&& candidate.cell.requestOnly !== true
&& candidate.cell.kind !== 'system').length <= 1) return
event.preventDefault()
onToggleTurn(record.turn)
}}
onKeyDown={(event) => {
if (isRequestOnly) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
@@ -1410,13 +1598,12 @@ export function TrajectoryTable({
{request !== undefined && (
<button
type="button"
className={css.requestBoundaryControl}
aria-label={`Request #${request}`}
aria-pressed={
selectedRequest?.turn === record.turn
&& selectedRequest.number === request
}
data-label={`Request #${request}`}
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
onClick={(event) => {
event.stopPropagation()
selectRequest({
@@ -1428,26 +1615,35 @@ export function TrajectoryTable({
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && (
{activeTurn === record.turn && !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
&& !isRequestOnly
&& record.turnStart && (
<span
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
</span>
)}
<div className={css.eventInner}>
{!isCollapsedSummary && (
{!isCollapsedSummary && !isRequestOnly && (
<span
className={
record.cell.kind === 'user'
|| record.cell.kind === 'context'
|| record.cell.kind === 'message'
? `${css.kindSlot} ${css.kindSlotLeft}`
: `${css.kindSlot} ${css.kindSlotRight}`
}
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'context'
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
@@ -1459,21 +1655,14 @@ export function TrajectoryTable({
>
{KIND_LABEL[record.cell.kind]}
</span>
{record.turnStart && record.cell.opensTurn && (
<span
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
</span>
)}
</span>
)}
</div>
</td>
<td className={css.content}>
{record.collapsedSummary !== undefined
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}></span>
@@ -1508,7 +1697,12 @@ export function TrajectoryTable({
{record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}></span>
<span className={css.inlineResultText}>{record.cell.result}</span>
<span className={record.cell.result === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
</span>
</span>
)}
</span>
@@ -1611,18 +1805,27 @@ export function TrajectoryTable({
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
</span>
<span className={css.detailsLocation}>Turn {selectedRequest.turn}</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
</span>
</>
)
: promptSelected
? (
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<span className={css.detailsLocation}>{selected?.cell.text}</span>
</>
)
: selected !== undefined && (
<>
<span className={`${css.kindTag} ${
selected.cell.kind === 'context'
? css.contextGreen
: selected.cell.kind === 'compacted'
? css.compacted
: selected.cell.kind === 'tool'
? css.toolAmber
: selected.cell.kind === 'message'
@@ -1634,7 +1837,11 @@ export function TrajectoryTable({
>
{KIND_LABEL[selected.cell.kind]}
</span>
<span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span>
<span className={css.detailsLocation}>
{selected.cell.kind === 'compacted'
? `Turn ${selected.turn}`
: `Turn ${selected.turn} · ${selected.group}`}
</span>
</>
)}
</div>
@@ -1668,7 +1875,9 @@ export function TrajectoryTable({
</div>
<div
id="trajectory-detail-panel"
className={css.detailBody}
className={activeTab === 'overview'
? `${css.detailBody} ${css.detailBodySummary}`
: css.detailBody}
role="tabpanel"
aria-labelledby={`trajectory-detail-${activeTab}`}
>
@@ -1681,6 +1890,12 @@ export function TrajectoryTable({
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
</div>
{selectedRequestInfo?.purpose === 'compaction' && (
<div>
<dt>Purpose</dt>
<dd>Compaction</dd>
</div>
)}
{(selectedRequestInfo?.provider
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
<div>
@@ -1711,7 +1926,30 @@ export function TrajectoryTable({
<dd>{selectedRequestSubtoolCalls}</dd>
</div>
)}
{selectedRequestAssistant !== undefined && (
{selectedRequestInfo?.error !== undefined && (
<div>
<dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd>
</div>
)}
{selectedRequestInfo?.retry !== undefined && (
<div>
<dt>Retry</dt>
<dd>
Scheduled {selectedRequestInfo.retry}
{selectedRequestInfo.maxRetries === undefined
? ''
: ` of ${selectedRequestInfo.maxRetries}`}
</dd>
</div>
)}
{selectedRequestInfo?.retryDelayMs !== undefined && (
<div>
<dt>Retry delay</dt>
<dd>{formatDurationMs(selectedRequestInfo.retryDelayMs)}</dd>
</div>
)}
{selectedRequestResult !== undefined && (
<div>
<dt>Result</dt>
<dd className={css.overviewParentLinks}>
@@ -1719,10 +1957,14 @@ export function TrajectoryTable({
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
openRecordSummary(selectedRequestAssistant)
openRecordSummary(selectedRequestResult)
}}
>
<span>Assistant Message</span>
<span>
{selectedRequestInfo?.purpose === 'compaction'
? 'Compacted'
: 'Assistant Message'}
</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -1745,6 +1987,7 @@ export function TrajectoryTable({
<RequestTiming
assistant={selectedRequestAssistant}
anchor={selectedRequestAnchor}
request={selectedRequestInfo}
/>
</OverviewSection>
</div>
@@ -1763,30 +2006,93 @@ export function TrajectoryTable({
<RequestTiming
assistant={selectedRequestAssistant}
anchor={selectedRequestAnchor}
request={selectedRequestInfo}
/>
)}
{promptSelected
&& selectedPreviousPrompt !== undefined
&& activeTab === 'diff' && (
<SystemPromptDiff
before={selectedPreviousPrompt}
after={selectedPrompt}
/>
)}
{promptSelected && activeTab === 'system-prompt' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: prompt.system === ''
selectedPrompt.system === ''
? <p className={css.noPayload}>No system prompt in this request</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={prompt.system} />
<MarkdownText text={selectedPrompt.system} />
</div>
)
)}
{promptSelected && activeTab === 'tools' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: <ToolCatalog tools={prompt.tools} />
<ToolCatalog tools={selectedPrompt.tools} />
)}
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
{!promptSelected
&& selected?.cell.kind === 'compacted'
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>{tokenSummary(selected.cell)}</dd>
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<MarkdownRecordContent
record={selected}
rendered
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
</div>
)}
</>
)}
{!promptSelected
&& selected !== undefined
&& selected.cell.kind !== 'compacted'
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
<dd className={css.overviewParentLinks}>
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => { activateTab('origin') }}
>
<span>{messageOriginLabel(selected.cell.messageSource)}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
</dd>
</div>
)}
{hasSelectedHierarchy && (
<div>
<dt>Hierarchy</dt>
<dt>
{selectedAssistantRequestTarget !== undefined
? 'Origin'
: 'Hierarchy'}
</dt>
<dd className={css.overviewParentLinks}>
{selectedAssistantRequestTarget !== undefined && (
<button
@@ -1915,6 +2221,9 @@ export function TrajectoryTable({
onOpenCall={openCallSummary}
/>
)}
{!promptSelected && selected !== undefined && activeTab === 'origin' && (
<MessageOrigin record={selected} />
)}
{!promptSelected && selected !== undefined && activeTab === 'input' && (
<RecordPayload record={selected} direction="input" />
)}

View File

@@ -32,36 +32,6 @@
font: var(--dsw-font-xs-strong-13);
}
.separator {
flex: none;
color: var(--dsw-alias-label-caption);
user-select: none;
}
.context {
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
text-overflow: ellipsis;
white-space: nowrap;
}
.contextCurrent,
.contextFrozen {
flex: none;
font: 10px/16px var(--ds-font-family-code);
user-select: none;
}
.contextCurrent {
color: var(--dsw-alias-state-business-primary);
}
.contextFrozen {
color: var(--dsw-alias-label-caption);
}
.actions {
display: flex;
flex: none;
@@ -74,7 +44,7 @@
flex: none;
align-items: center;
box-sizing: border-box;
height: 26px;
height: 24px;
padding: 0 7px;
gap: 6px;
border: 0;

View File

@@ -3,10 +3,6 @@
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Selected context title when the session contains discontinuities. */
contextLabel?: string
/** Whether the selected context is the live tail. */
contextCurrent?: boolean
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
@@ -27,8 +23,6 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
contextLabel,
contextCurrent,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
@@ -41,15 +35,6 @@ export function TrajectoryToolbar({
<div className={css.inner}>
<div className={css.summary}>
<span className={css.title}>Trajectory</span>
{contextLabel !== undefined && (
<>
<span className={css.separator}>/</span>
<span className={css.context}>{contextLabel}</span>
<span className={contextCurrent ? css.contextCurrent : css.contextFrozen}>
{contextCurrent ? 'Current' : 'Frozen'}
</span>
</>
)}
</div>
<div className={css.actions}>
<button

View File

@@ -3,9 +3,12 @@
import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, ConversationContext,
AssistantMessageNode, CompactionRequestView, ConversationContext,
ConversationPromptChange, ModelRequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -16,6 +19,9 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
interface UsageLike {
inputTokens?: number
@@ -61,22 +67,21 @@ function addUsage(
}
}
function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext {
const context = contexts.at(-1)
if (context === undefined) throw new Error('trajectory context projection must not be empty')
return context
}
export function TrajectoryView({ useSession }: ConvViewProps) {
const [selectedContextId, setSelectedContextId] = useState<number | null>(null)
const [collapsedTurnsByContext, setCollapsedTurnsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const [collapsedAssistantsByContext, setCollapsedAssistantsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const nodes = useSession((s) => s.nodes)
const projectedContexts = useSession((s) => s.contexts)
const compactionRequests = useSession(
(s) => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
)
const requestAttempts = useSession(
(s) => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
)
const promptChanges = useSession(
(s) => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const callSchemas = useSession((s) => s.callSchemas)
@@ -87,95 +92,201 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
: projectedContexts,
[nodes, projectedContexts],
)
const currentContext = currentContextOf(contexts)
const selectedContext = selectedContextId === null
? currentContext
: contexts.find(context => context.id === selectedContextId) ?? currentContext
const viewingCurrent = selectedContext.id === currentContext.id
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestsBySeq = new Map<number, AssistantMessageNode>()
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = currentBranch.nodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, 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)
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
requestsBySeq.set(node.seq, node)
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const orderedRequests = [...requestsBySeq.values()]
.sort((left, right) => left.seq - right.seq)
const requestBySeq = new Map<number, TrajectoryRequestNumber>()
const attemptsByStep = new Map(
requestAttempts.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requestAttempts.map(request => ({
seq: request.startSeq,
kind: 'ordinary' as const,
request,
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
attemptsByStep.has(key)
? []
: [{
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
),
...compactionRequests.map(request => ({
seq: request.startSeq,
kind: 'compaction' as const,
request,
node: undefined,
})),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, node] of orderedRequests.entries()) {
const usage = requestUsage(node.usage)
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(
entry.kind === 'compaction'
? entry.request.usage
: entry.request?.usage ?? entry.node?.usage,
)
cumulativeUsage = addUsage(cumulativeUsage, usage)
requestBySeq.set(node.seq, {
turn: node.turn,
step: node.step,
if (entry.kind === 'ordinary') {
const request = entry.request
const node = entry.node
const turn = request?.turn ?? node?.turn
const step = request?.step ?? node?.step
if (turn === undefined || step === undefined) continue
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
numbered.push({
seq: entry.seq,
turn,
step,
group: `Step ${step}`,
number: index + 1,
...(request?.status === undefined ? {} : { status: request.status }),
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
...(request?.error === undefined ? {} : { error: request.error }),
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
...(request?.retry === undefined ? {} : { retry: request.retry }),
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
...(request?.retryDelayMs === undefined
? {}
: { retryDelayMs: request.retryDelayMs }),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(requestConfig === undefined ? {} : { requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
continue
}
const request = entry.request
numbered.push({
seq: request.startSeq,
turn: request.turn,
step: 0,
group: `Compaction ${request.startSeq}`,
number: index + 1,
...(node.provenance?.provider === undefined
purpose: 'compaction',
status: request.status,
startedAt: request.startedAt,
completedAt: request.completedAt,
...(request.error === undefined ? {} : { error: request.error }),
resultSeq: request.startSeq,
...(request.provenance?.provider === undefined
? {}
: { provider: node.provenance.provider }),
...(node.provenance?.model === undefined
: { provider: request.provenance.provider }),
...(request.provenance?.model === undefined
? {}
: { model: node.provenance.model }),
...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }),
: { model: request.provenance.model }),
...(request.requestConfig === undefined ? {} : { requestConfig: request.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) {
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
if (!selectedKeys.has(key)) {
selected.push({
const recorded = numbered.some(request =>
`${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn,
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentContext.prompt?.config?.provider === undefined
...(currentBranch.latest.prompt?.config?.provider === undefined
? {}
: { provider: currentContext.prompt.config.provider }),
...(currentContext.prompt?.config?.model === undefined
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config?.model === undefined
? {}
: { model: currentContext.prompt.config.model }),
...(currentContext.prompt?.config === undefined
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentContext.prompt.config }),
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return selected
}, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent])
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
return numbered
}, [
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
requestAttempts,
])
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
return globalRequestNumbers.filter(request =>
request.seq === undefined
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
)
}, [currentBranch, globalRequestNumbers])
const visibleRequestAttempts = useMemo(
() => requestAttempts.filter(request =>
requestNumbers.some(number =>
number.purpose !== 'compaction' && number.seq === request.startSeq,
)),
[requestAttempts, requestNumbers],
)
const visibleCompactionRequests = useMemo(
() => compactionRequests.filter(request =>
requestNumbers.some(number =>
number.purpose === 'compaction' && number.seq === request.startSeq,
)),
[compactionRequests, requestNumbers],
)
const visiblePromptChanges = useMemo(
() => promptChanges.filter(change =>
trajectoryBranchContainsSeq(currentBranch, change.seq)),
[currentBranch, promptChanges],
)
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial: viewingCurrent ? partial : null,
runningCalls: viewingCurrent ? runningCalls : [],
partial,
runningCalls,
compactionRequests: visibleCompactionRequests,
requestAttempts: visibleRequestAttempts,
promptChanges: visiblePromptChanges,
callSchemas,
codeDispatches,
}),
[
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches,
selectedNodes, partial, runningCalls, visibleCompactionRequests,
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
],
)
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1)
.filter(turn =>
turn.groups.reduce(
(count, group) =>
count + group.cells.filter(cell =>
cell.requestOnly !== true && cell.kind !== 'system').length,
0,
) > 1)
.map(turn => turn.turn),
[turns],
)
@@ -198,68 +309,50 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => {
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn)
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAllTurns = () => {
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
} else {
for (const turn of collapsibleTurnIds) collapsed.add(turn)
}
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAllAssistants = () => {
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) collapsed.delete(index)
} else {
for (const index of collapsibleAssistantIds) collapsed.add(index)
}
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const selectContext = (id: number) => {
setSelectedContextId(id === currentContext.id ? null : id)
}
return (
<div className={css.root}>
<TrajectoryToolbar
{...contexts.length > 1
? {
contextLabel: contextLabel(selectedContext),
contextCurrent: viewingCurrent,
}
: {}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
@@ -267,27 +360,16 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
<div className={css.contextLayout}>
{contexts.length > 1 && (
<ContextsPanel
contexts={contexts}
selectedId={selectedContext.id}
currentId={currentContext.id}
onSelect={selectContext}
/>
)}
<div className={css.ledger}>
<TrajectoryTable
key={selectedContext.id}
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
requestNumbers={requestNumbers}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
requestNumbers={requestNumbers}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
</div>
)

View File

@@ -0,0 +1,123 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
ranges: readonly TrajectoryBranchRange[]
}
/** One half-open session-event range carried by a rewind branch. */
export interface TrajectoryBranchRange {
start: number
end: number
}
interface MutableBranch {
id: number
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
ranges: TrajectoryBranchRange[]
}
function isCompactionCheckpoint(node: ConversationNode): boolean {
if (node.kind !== 'context') return false
const source = node.source
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
/**
* Join context generations across compaction/rewrite operations and split only at rewind.
* @param contexts - Append-only context generations from the runtime fold.
* @returns Rewind-delimited branches in creation order.
*/
export function deriveTrajectoryContextBranches(
contexts: readonly ConversationContext[],
): readonly TrajectoryContextBranch[] {
const mutable: MutableBranch[] = []
for (const context of contexts) {
const startsBranch = mutable.length === 0 || context.origin === 'rewind'
if (startsBranch) {
const previous = mutable.at(-1)
const originSeq = context.originSeq ?? Number.POSITIVE_INFINITY
if (previous !== undefined) {
const openRange = previous.ranges.at(-1)
if (openRange === undefined) {
throw new Error('trajectory branch must contain an open event range')
}
openRange.end = originSeq
}
const retainedCutoff = Math.max(
Number.NEGATIVE_INFINITY,
...context.nodes
.filter(node => node.seq < originSeq)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node => node.seq <= retainedCutoff)
const inheritedRanges = previous === undefined
? []
: previous.ranges.flatMap((range) => {
const end = Math.min(range.end, retainedCutoff + 1)
return end <= range.start ? [] : [{ start: range.start, end }]
})
mutable.push({
id: context.id,
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [node.seq, node]),
),
ranges: [
...inheritedRanges,
{
start: context.originSeq ?? Number.NEGATIVE_INFINITY,
end: Number.POSITIVE_INFINITY,
},
],
})
continue
}
const branch = mutable.at(-1)
if (branch === undefined) continue
branch.contexts.push(context)
branch.latest = context
for (const node of context.nodes) {
if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node)
}
}
return mutable.map(branch => ({
id: branch.id,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),
ranges: branch.ranges,
}))
}
/**
* Test whether a session event belongs to one rewind branch's continuous history.
* @param branch - Branch carrying inherited and post-rewind log ranges.
* @param seq - Session event sequence.
* @returns Whether the event belongs to the branch.
*/
export function trajectoryBranchContainsSeq(
branch: TrajectoryContextBranch,
seq: number,
): boolean {
return branch.ranges.some(range => seq >= range.start && seq < range.end)
}

View File

@@ -6,7 +6,10 @@ import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
CompactionRequestView,
ConversationPromptChange,
ConversationSnapshot,
ModelRequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
@@ -32,6 +35,9 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
compactionRequests?: readonly CompactionRequestView[]
requestAttempts?: readonly ModelRequestView[]
promptChanges?: readonly ConversationPromptChange[]
callSchemas?: ConversationSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
@@ -62,13 +68,46 @@ interface TurnBucket {
groups: LaidGroup[]
}
type OrderedLayoutEntry =
| {
kind: 'node'
seq: number
node: ConversationSnapshot['nodes'][number]
nodeIndex: number
}
| {
kind: 'compaction'
seq: number
request: CompactionRequestView
}
| {
kind: 'system'
seq: number
change: ConversationPromptChange
}
| {
kind: 'request'
seq: number
request: ModelRequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
return entry.kind === 'system' && entry.change.kind === 'initial'
? Number.NEGATIVE_INFINITY
: entry.seq
}
/**
* Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls.
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input
const {
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
promptChanges = [],
callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
@@ -114,10 +153,136 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
groups.push({ title, laid: [...laid] })
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (node === undefined) continue
const representedRequests = new Set<string>()
for (const node of nodes) {
if (node.kind === 'assistant' && node.step > 0) {
representedRequests.add(`${node.turn}\u0000${node.step}`)
}
}
if (partial !== null && partial.step > 0) {
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
}
for (const call of runningCalls) {
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
}
const entries: OrderedLayoutEntry[] = [
...nodes.map((node, nodeIndex) => ({
kind: 'node' as const,
seq: node.seq,
node,
nodeIndex,
})),
...compactionRequests.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...promptChanges.map(change => ({
kind: 'system' as const,
seq: change.seq,
change,
})),
...requestAttempts
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
.map(request => ({
kind: 'request' as const,
seq: request.startSeq,
request,
})),
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
for (const entry of entries) {
if (entry.kind === 'request') {
const { request } = entry
pushStep(request.turn, request.step, [{
absTime: finiteTime(request.startedAt),
cell: {
index: ++index,
kind: 'message',
text: '',
sourceSeq: request.startSeq,
requestOnly: true,
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
...(request.status === 'error' ? { isError: true } : {}),
},
}])
prevAbsTime = finiteTime(request.completedAt)
?? finiteTime(request.startedAt)
?? prevAbsTime
continue
}
if (entry.kind === 'system') {
const { change } = entry
const turn = enclosingPromptTurn(nodes, change.seq, partial)
pushMessage(turn, {
absTime: finiteTime(change.time),
cell: {
index: ++index,
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
promptDetail: change.prompt,
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
timeSeconds: 0,
startedAt: finiteTime(change.time),
},
})
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
continue
}
if (entry.kind === 'compaction') {
const request = entry.request
const rawOutput = request.rawOutput ?? request.summary
const thinkingDetail = rawOutput === undefined
? ''
: detailReasoning(rawOutput)
const cell: TrajectoryCellProps = {
index: ++index,
kind: 'compacted',
text: request.status === 'running'
? 'Compacting context…'
: request.status === 'error'
? request.error ?? 'Compaction failed'
: request.summary === undefined
? 'Context compacted'
: summarizeContent(request.summary),
sourceSeq: request.startSeq,
...(request.summary === undefined
? {}
: {
outputDetail: detailContent(request.summary),
outputBlocks: request.summary.map(block => sourceBlock(block)),
}),
...(thinkingDetail === '' ? {} : { thinkingDetail }),
...(rawOutput === undefined
? {}
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
...(request.status === 'error' ? { isError: true } : {}),
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
}
attachUsage(cell, request.usage as UsageLike | undefined)
bucket(request.turn).groups.push({
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
}],
})
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
continue
}
const { node, nodeIndex: i } = entry
if (node.kind === 'user' || node.kind === 'steering') {
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
@@ -128,6 +293,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
opensTurn: node.kind === 'user',
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
@@ -159,6 +327,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'context',
text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
@@ -178,6 +349,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: {
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
@@ -241,7 +413,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
laidList.push(laid)
index = laid.cell.index
}
pushStep(call.turn, call.step > 0 ? call.step : 1, laidList)
if (call.step > 0) pushStep(call.turn, call.step, laidList)
else for (const laid of laidList) pushMessage(call.turn, laid)
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -365,6 +538,7 @@ function expandAssistant(
const message: TrajectoryCellProps = {
index: ++index,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
? summarizeText(messageText)
: thinkingText !== ''
@@ -432,6 +606,13 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
return ''
}
function promptChangeLabel(change: ConversationPromptChange): string {
if (change.kind === 'initial') return 'Initial System Prompt'
if (change.kind === 'system') return 'System Prompt Updated'
if (change.kind === 'tools') return 'Tools Updated'
return 'System Prompt and Tools Updated'
}
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
switch (block.kind) {
case 'text': return { type: 'text', content: block.text }
@@ -524,6 +705,17 @@ function enclosingUserTurn(
return 1
}
function enclosingPromptTurn(
nodes: ConversationSnapshot['nodes'],
seq: number,
partial: ConversationSnapshot['partial'],
): number {
const next = nodes.find(node =>
node.seq > seq && node.kind === 'assistant' && node.step > 0)
if (next?.kind === 'assistant') return next.turn
return partial?.turn ?? 1
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return
@@ -641,7 +833,7 @@ function summarizeResult(node: ToolResultNode): string {
return summarizeText(block.text)
}
}
return node.call?.name ?? node.callId
return 'No output'
}
function detailResult(node: ToolResultNode): string {
@@ -655,6 +847,11 @@ function detailResult(node: ToolResultNode): string {
.map(block => block.type === 'text' ? block.text : '')
.join('\n')
if (text !== '') return text
if (
node.content.length === 0
|| node.content.every(block =>
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
) return 'No output'
return JSON.stringify(node.content, null, 2)
}
@@ -665,6 +862,13 @@ function detailContent(content: readonly { type: string; text?: string }[]): str
.join('\n')
}
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
return content
.filter(block => block.type === 'reasoning' && 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

@@ -1,9 +1,17 @@
/** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Closed set of trajectory record kinds. */
export type TrajectoryCellKind = 'user' | 'context' | 'message' | 'tool' | 'subtool'
export type TrajectoryCellKind =
| 'system'
| 'user'
| 'context'
| 'compacted'
| 'message'
| 'tool'
| 'subtool'
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
export interface AssistantMetricDetail {
@@ -34,8 +42,20 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
text: string
/** Whether this user record opens a new model turn. */
opensTurn?: boolean
/** Source session-event seq for cross-record navigation. */
sourceSeq?: number
/** Producer provenance from a user-role message or context injection. */
messageSource?: unknown
/** Producer-owned model-hidden metadata carried beside the message source. */
messageMeta?: unknown
/** A separator-only anchor for an auxiliary request with no visible record. */
requestOnly?: boolean
/** Full request/message content for the details panel. */
inputDetail?: string
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
promptDetail?: ConversationPromptSnapshot
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
previousPromptDetail?: ConversationPromptSnapshot
/** Full assistant/tool result content for the details panel. */
outputDetail?: string
/** Full assistant reasoning content for the details panel. */

View File

@@ -1,6 +1,6 @@
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
.root {
--dsh-trajectory-toolbar-height: 48px;
--dsh-trajectory-toolbar-height: 40px;
display: flex;
flex-direction: column;
@@ -23,14 +23,6 @@
font: var(--dsw-font-xs-13);
}
.contextLayout {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.ledger {
display: flex;
flex: 1;