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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7
2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c 2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99

View File

@@ -12,12 +12,13 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.**
- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. - The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests.
- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector.
- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack.
- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus.
- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data.
- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer.
## Alternatives considered ## Alternatives considered
@@ -25,7 +26,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower.
**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. **Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows.
**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state.
@@ -33,4 +34,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
## Consequences ## Consequences
Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. Focused component tests pin projection, folding, selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger and inspector through the real client composition.

View File

@@ -12,12 +12,13 @@ Status: implemented
**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
- 轮次边界由记录行之间较粗的分割线表示每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进 - 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示请求边界以融入该结构的小圆点表示普通请求与压缩compaction请求在整个时间序列中共用一套编号
- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈 - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中
- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈
- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点
- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文 - 选择记录或请求后,轨迹视图内部会打开检查器其标签页和概览区域随实体类型变化Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据
- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
## 曾考虑的替代方案 ## 曾考虑的替代方案
@@ -25,7 +26,7 @@ Status: implemented
**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记 **不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
@@ -33,4 +34,4 @@ Status: implemented
## 后果 ## 后果
轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果 轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器

View File

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

View File

@@ -51,6 +51,7 @@ export interface UserMessageNode {
time: number time: number
content: readonly ContentBlock[] content: readonly ContentBlock[]
source: unknown source: unknown
meta?: unknown
} }
/** Recorded boundaries used to derive assistant latency and throughput. */ /** Recorded boundaries used to derive assistant latency and throughput. */
@@ -67,6 +68,7 @@ export interface AssistantTiming {
export interface AssistantRequestConfig { export interface AssistantRequestConfig {
provider: string provider: string
model: string model: string
purpose?: string
thinking?: string thinking?: string
reasoningEffort?: string reasoningEffort?: string
temperature?: number temperature?: number
@@ -108,6 +110,7 @@ export interface SteeringMessageNode {
turn: number turn: number
content: readonly ContentBlock[] content: readonly ContentBlock[]
source: unknown source: unknown
meta?: unknown
} }
/** A context/system injection surfaced in the flow. */ /** A context/system injection surfaced in the flow. */
@@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot {
tools: readonly ToolSchema[] 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. */ /** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext { export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */ /** Zero-based generation within the session; stable across later appends. */
@@ -259,6 +276,46 @@ export interface ConversationContext {
nodes: readonly ConversationNode[] 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 停止失败). */ /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError { export interface PromptError {
op: 'send' | 'stop' op: 'send' | 'stop'
@@ -272,6 +329,12 @@ export interface ConversationSnapshot {
nodes: readonly ConversationNode[] nodes: readonly ConversationNode[]
/** Append-only context generations split at every model-surface replacement. */ /** Append-only context generations split at every model-surface replacement. */
contexts?: readonly ConversationContext[] 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. */ /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean foldDegraded: boolean
partial: PartialAssistant | null partial: PartialAssistant | null

View File

@@ -59,6 +59,7 @@ function materializeNode(
return { return {
kind: 'user', seq: event.seq, time: event.time, kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source, content: event.data.content, source: event.data.source,
meta: event.data.meta,
} }
case 'assistant/message': case 'assistant/message':
return { return {
@@ -76,6 +77,7 @@ function materializeNode(
return { return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source, content: event.data.content, source: event.data.source,
meta: event.data.meta,
} }
case 'tool/result': { case 'tool/result': {
const call = callIndex.get(String(event.data.callId)) 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 { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts' import type { ObservableSnapshot } from '../contract/store.ts'
import type { import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode,
PromptError, QueuedMessage, RunningToolCall, ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot,
ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts' } from './conversation.ts'
import type { PendingInteraction } from './pending.ts' import type { PendingInteraction } from './pending.ts'
import { PendingWait } 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 callSchemas = new Map<string, ToolSchema>()
private callSchemasRev = 0 private callSchemasRev = 0
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null 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 private running = false
/** /**
* Sticky send marker, private input of the composerPhase derivation: set * Sticky send marker, private input of the composerPhase derivation: set
@@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
} }
switch (event.type) { switch (event.type) {
case 'request/header': { case 'request/header': {
this.promptChangesRev++
this.activeToolSchemas = new Map( this.activeToolSchemas = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]), (event.data.header.tools ?? []).map(schema => [schema.name, schema]),
) )
@@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.activeToolSchemas = new Map() this.activeToolSchemas = new Map()
this.callSchemas = new Map() this.callSchemas = new Map()
this.callSchemasRev++ this.callSchemasRev++
this.promptChangesRev++
for (let i = 0; i < this.events.length; i++) { for (let i = 0; i < this.events.length; i++) {
const event = this.events[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. */ /* 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) { if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } 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 const partial = this.partial?.toPartial() ?? null
return { return {
sessionId: this.sessionId, sessionId: this.sessionId,
nodes, nodes,
contexts, contexts,
compactionRequests: deriveCompactionRequests(this.events),
requestAttempts: deriveModelRequests(this.events),
promptChanges: this.promptChangesCache.value,
foldDegraded: degraded, foldDegraded: degraded,
partial, partial,
runningCalls: this.callsCache.value, runningCalls: this.callsCache.value,
@@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
if (hasContent) return 'active' if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank' 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 * lightningcss inside the bundle: importing `x.module.css` yields the
* hashed class map, and the css text auto-injects a <style data-plugin="<id>"> * 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). * 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 { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path' 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) { async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId) const source = await readFile(fileId)
const { code, exports: cssExports } = transform({ const { code, exports: cssExports } = transform({
filename: fileId, filename: fileId,

View File

@@ -49,9 +49,7 @@
} }
/* Drag handles are frame children (columns clip overflow): an 8px hit strip /* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The centered on the column border via inline left, above column content. */
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
.handle { .handle {
position: absolute; position: absolute;
z-index: 6; z-index: 6;
@@ -75,36 +73,3 @@
transition: none; 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 { .expandedTopLevelContainer {
padding: 0 0 0 calc(2ch - 12px); padding: 0;
} }
.row.topLevelBracket { .row.topLevelBracket {
@@ -59,7 +59,7 @@
.children { .children {
margin: 0; margin: 0;
padding: 0 0 0 4px; padding: 0;
list-style: none; list-style: none;
} }
@@ -69,7 +69,7 @@
min-width: 100%; min-width: 100%;
min-height: 16px; min-height: 16px;
margin: 0; margin: 0;
padding: 0 0 0 12px; padding: 0 0 0 10px;
list-style: none; list-style: none;
} }
@@ -178,7 +178,7 @@
left: 0; left: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
box-sizing: border-box; box-sizing: border-box;
width: 8px; width: 8px;
height: 16px; height: 16px;
@@ -196,7 +196,7 @@
border-left: 6px solid currentColor; border-left: 6px solid currentColor;
content: ''; content: '';
transform: scale(0.75); transform: scale(0.75);
transform-origin: center; transform-origin: 33.333% center;
} }
.collapseIcon::before { .collapseIcon::before {

View File

@@ -66,6 +66,17 @@
font: inherit; font: inherit;
} }
.noLanguage .bannerWrap {
position: absolute;
right: 0;
background: transparent;
}
.noLanguage .banner {
padding-left: 8px;
background: transparent;
}
.block :where(pre) { .block :where(pre) {
font: var(--dsl-code-block-content-font); font: var(--dsl-code-block-content-font);
padding: 16px; padding: 16px;
@@ -76,6 +87,10 @@
background: var(--dsw-alias-markdown-code-block); 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. */ /* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) { .block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important; 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) { export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const hasLanguage = lang !== undefined && lang !== ''
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang]) const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -80,7 +81,10 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
) )
return ( 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.bannerWrap}>
<div className={css.banner}> <div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div> <div className={css.infostring}>{lang ?? ''}</div>

View File

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

View File

@@ -33,6 +33,9 @@
"watch": "tsdown --watch" "watch": "tsdown --watch"
}, },
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": {
"diff": "^9.0.0"
},
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^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; white-space: nowrap;
} }
.tagSystem {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.tagUser { .tagUser {
color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-state-success-tertiary); background: var(--dsw-alias-state-success-tertiary);

View File

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

View File

@@ -18,7 +18,7 @@
.table { .table {
--trajectory-turn-accent: color-mix( --trajectory-turn-accent: color-mix(
in srgb, in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 22%, var(--dsw-static-blue-500) 22%,
var(--dsw-alias-bg-layer-1) var(--dsw-alias-bg-layer-1)
); );
@@ -28,7 +28,7 @@
table-layout: fixed; table-layout: fixed;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-xs-13); font: var(--dsw-font-xxs-12);
} }
.eventColumn { .eventColumn {
@@ -50,7 +50,7 @@
border-bottom: 1px solid var(--dsw-alias-border-l2); border-bottom: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
background: var(--dsw-specific-sidebar-fill); background: var(--dsw-specific-sidebar-fill);
font: var(--dsw-font-xs-13); font: var(--dsw-font-xxs-12);
font-weight: 500; font-weight: 500;
text-align: left; text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -83,6 +83,22 @@
background: var(--dsw-alias-interactive-bg-hover); 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 { .table tbody tr:not([data-collapsed-summary]):focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
} }
@@ -95,7 +111,7 @@
position: absolute; position: absolute;
z-index: 6; z-index: 6;
top: -8px; top: -8px;
left: 6px; left: 2px;
width: 16px; width: 16px;
height: 16px; height: 16px;
padding: 0; padding: 0;
@@ -106,10 +122,10 @@
.requestBoundaryControl::before { .requestBoundaryControl::before {
position: absolute; position: absolute;
top: 6px; top: 5.5px;
left: 6px; left: 5.5px;
width: 4px; width: 5px;
height: 4px; height: 5px;
border-radius: 50%; border-radius: 50%;
background: var(--dsw-alias-label-caption); background: var(--dsw-alias-label-caption);
box-shadow: box-shadow:
@@ -118,7 +134,7 @@
content: ''; content: '';
transition: transition:
background 120ms var(--ds-ease-in-out), background 120ms var(--ds-ease-in-out),
transform 120ms var(--ds-ease-in-out); box-shadow 120ms var(--ds-ease-in-out);
} }
.requestBoundaryControl::after { .requestBoundaryControl::after {
@@ -127,9 +143,11 @@
left: 17px; left: 17px;
width: max-content; width: max-content;
padding: 0 4px; padding: 0 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 2px; border-radius: 2px;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
content: attr(data-label); content: attr(data-label);
font: 9px/12px var(--ds-font-family-code); font: 9px/12px var(--ds-font-family-code);
opacity: 0; opacity: 0;
@@ -143,10 +161,19 @@
} }
.requestBoundaryControl:hover::before, .requestBoundaryControl:hover::before,
.requestBoundaryControl:focus-visible::before, .requestBoundaryControl:focus-visible::before {
.requestBoundaryControl[aria-pressed='true']::before { background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: var(--dsw-alias-label-primary); }
transform: scale(1.25);
.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, .requestBoundaryControl:hover::after,
@@ -219,14 +246,17 @@
} }
.turnLabel { .turnLabel {
position: absolute;
z-index: 3;
top: 0;
left: 0;
display: inline-flex; display: inline-flex;
flex: none; flex: none;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
width: max-content; width: max-content;
margin-left: 4px; padding: 1px 5px;
padding: 1px 2px; border-radius: 0 0 2px;
border-radius: 2px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
background: var(--dsw-alias-bg-module-platform); background: var(--dsw-alias-bg-module-platform);
font: 8px/10px var(--ds-font-family-code); font: 8px/10px var(--ds-font-family-code);
@@ -241,11 +271,7 @@
var(--dsw-static-blue-500) 55%, var(--dsw-static-blue-500) 55%,
var(--dsw-alias-label-tertiary) var(--dsw-alias-label-tertiary)
); );
background: color-mix( background: var(--trajectory-turn-accent);
in srgb,
var(--dsw-static-blue-500) 10%,
var(--dsw-alias-bg-layer-1)
);
} }
.eventInner { .eventInner {
@@ -260,15 +286,8 @@
display: flex; display: flex;
flex: none; flex: none;
align-items: flex-end; align-items: flex-end;
width: 76px;
}
.kindSlotLeft {
justify-content: flex-start;
}
.kindSlotRight {
justify-content: flex-end; justify-content: flex-end;
width: 76px;
} }
.content { .content {
@@ -316,6 +335,80 @@
background: var(--dsw-alias-state-success-tertiary); 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 { .assistantVioletBright {
color: color-mix( color: color-mix(
in srgb, in srgb,
@@ -359,8 +452,9 @@
white-space: nowrap; white-space: nowrap;
} }
.table tbody tr[data-collapsed-summary] td { .table tbody tr[data-collapsed-summary='turn'] td,
height: 24px; .table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
} }
.table tbody tr[data-collapsed-summary] { .table tbody tr[data-collapsed-summary] {
@@ -383,7 +477,7 @@
min-width: 0; min-width: 0;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 16px;
} }
.collapsedTurnEllipsis { .collapsedTurnEllipsis {
@@ -425,7 +519,7 @@
.toolCallNameTypeface { .toolCallNameTypeface {
color: var(--dsw-alias-label-primary); 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'; 'Microsoft YaHei';
} }
@@ -454,6 +548,10 @@
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
} }
.noOutputText {
color: var(--dsw-alias-label-caption);
}
.arrow { .arrow {
flex: none; flex: none;
margin-right: 8px; margin-right: 8px;
@@ -492,38 +590,6 @@
user-select: none; 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 { .detailsResizeHandle:focus-visible {
outline: none; outline: none;
} }
@@ -645,6 +711,28 @@
overflow: auto; 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 { .overview {
margin: 0; margin: 0;
padding: 8px 0; padding: 8px 0;
@@ -733,6 +821,20 @@
white-space: nowrap; 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 { .tokenEquation {
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
@@ -744,15 +846,34 @@
} }
.overviewSections { .overviewSections {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
border-top: 0; 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 { .overviewSection + .overviewSection {
padding-top: 8px; padding-top: 8px;
} }
.overviewHeading { .overviewHeading {
display: flex; display: flex;
flex: none;
align-items: flex-end; align-items: flex-end;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -801,7 +922,8 @@
} }
.overviewPreview { .overviewPreview {
max-height: 180px; flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-1);
@@ -865,6 +987,42 @@
font: var(--dsw-font-xs-13); 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 .markdownPayload,
.assistantContent .payload { .assistantContent .payload {
min-height: 0; min-height: 0;
@@ -1130,11 +1288,12 @@
all: unset; all: unset;
display: flex; display: flex;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: calc(100% + 4px);
min-width: 0; min-width: 0;
height: 19px; height: 19px;
align-items: center; align-items: center;
padding: 0 4px 0 0; margin-left: -4px;
padding: 0 4px;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }

View File

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

View File

@@ -32,36 +32,6 @@
font: var(--dsw-font-xs-strong-13); 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 { .actions {
display: flex; display: flex;
flex: none; flex: none;
@@ -74,7 +44,7 @@
flex: none; flex: none;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
height: 26px; height: 24px;
padding: 0 7px; padding: 0 7px;
gap: 6px; gap: 6px;
border: 0; border: 0;

View File

@@ -3,10 +3,6 @@
import css from './TrajectoryToolbar.module.css' import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps { 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. */ /** Number of turns containing more than one row. */
collapsibleTurns: number collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */ /** Whether every collapsible turn is currently folded. */
@@ -27,8 +23,6 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element. * @returns the toolbar element.
*/ */
export function TrajectoryToolbar({ export function TrajectoryToolbar({
contextLabel,
contextCurrent,
collapsibleTurns, collapsibleTurns,
allTurnsCollapsed, allTurnsCollapsed,
onToggleAllTurns, onToggleAllTurns,
@@ -41,15 +35,6 @@ export function TrajectoryToolbar({
<div className={css.inner}> <div className={css.inner}>
<div className={css.summary}> <div className={css.summary}>
<span className={css.title}>Trajectory</span> <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>
<div className={css.actions}> <div className={css.actions}>
<button <button

View File

@@ -3,9 +3,12 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { import type {
AssistantMessageNode, ConversationContext, AssistantMessageNode, CompactionRequestView, ConversationContext,
ConversationPromptChange, ModelRequestView,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx' import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
} from './context-branches.ts'
import { import {
TrajectoryTable, TrajectoryTable,
type TrajectoryRequestNumber, type TrajectoryRequestNumber,
@@ -16,6 +19,9 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css' import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set() const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
interface UsageLike { interface UsageLike {
inputTokens?: number 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) { export function TrajectoryView({ useSession }: ConvViewProps) {
const [selectedContextId, setSelectedContextId] = useState<number | null>(null) const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedTurnsByContext, setCollapsedTurnsByContext] = useState< const [collapsedAssistants, setCollapsedAssistants] =
ReadonlyMap<number, ReadonlySet<number>> useState<ReadonlySet<number>>(EMPTY_IDS)
>(() => new Map())
const [collapsedAssistantsByContext, setCollapsedAssistantsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const nodes = useSession((s) => s.nodes) const nodes = useSession((s) => s.nodes)
const projectedContexts = useSession((s) => s.contexts) 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 partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls) const runningCalls = useSession((s) => s.runningCalls)
const callSchemas = useSession((s) => s.callSchemas) const callSchemas = useSession((s) => s.callSchemas)
@@ -87,95 +92,201 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
: projectedContexts, : projectedContexts,
[nodes, projectedContexts], [nodes, projectedContexts],
) )
const currentContext = currentContextOf(contexts) const branches = useMemo(
const selectedContext = selectedContextId === null () => deriveTrajectoryContextBranches(contexts),
? currentContext [contexts],
: contexts.find(context => context.id === selectedContextId) ?? currentContext )
const viewingCurrent = selectedContext.id === currentContext.id const currentBranch = branches.at(-1)
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => { const selectedNodes = currentBranch.nodes
const requestsBySeq = new Map<number, AssistantMessageNode>() const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) { for (const context of contexts) {
for (const node of context.nodes) { for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue 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) { for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue 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()] const attemptsByStep = new Map(
.sort((left, right) => left.seq - right.seq) requestAttempts.map(request => [
const requestBySeq = new Map<number, TrajectoryRequestNumber>() `${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 let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, node] of orderedRequests.entries()) { for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(node.usage) const usage = requestUsage(
entry.kind === 'compaction'
? entry.request.usage
: entry.request?.usage ?? entry.node?.usage,
)
cumulativeUsage = addUsage(cumulativeUsage, usage) cumulativeUsage = addUsage(cumulativeUsage, usage)
requestBySeq.set(node.seq, { if (entry.kind === 'ordinary') {
turn: node.turn, const request = entry.request
step: node.step, 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, 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 }), : { provider: request.provenance.provider }),
...(node.provenance?.model === undefined ...(request.provenance?.model === undefined
? {} ? {}
: { model: node.provenance.model }), : { model: request.provenance.model }),
...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }), ...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }), ...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
}) })
} }
const selected: TrajectoryRequestNumber[] = [] if (partial !== null && partial.step > 0) {
const selectedKeys = new Set<string>()
for (const node of selectedNodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
const request = requestBySeq.get(node.seq)
if (request === undefined) continue
selected.push(request)
selectedKeys.add(`${node.turn}\u0000${node.step}`)
}
if (viewingCurrent && partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}` const key = `${partial.turn}\u0000${partial.step}`
if (!selectedKeys.has(key)) { const recorded = numbered.some(request =>
selected.push({ `${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn, turn: partial.turn,
step: partial.step, step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1, number: orderedRequests.length + 1,
...(currentContext.prompt?.config?.provider === undefined ...(currentBranch.latest.prompt?.config?.provider === undefined
? {} ? {}
: { provider: currentContext.prompt.config.provider }), : { provider: currentBranch.latest.prompt.config.provider }),
...(currentContext.prompt?.config?.model === undefined ...(currentBranch.latest.prompt?.config?.model === undefined
? {} ? {}
: { model: currentContext.prompt.config.model }), : { model: currentBranch.latest.prompt.config.model }),
...(currentContext.prompt?.config === undefined ...(currentBranch.latest.prompt?.config === undefined
? {} ? {}
: { requestConfig: currentContext.prompt.config }), : { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
}) })
} }
} }
return selected return numbered
}, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent]) }, [
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS 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( const turns = useMemo(
() => deriveTrajectoryLayout({ () => deriveTrajectoryLayout({
nodes: selectedNodes, nodes: selectedNodes,
partial: viewingCurrent ? partial : null, partial,
runningCalls: viewingCurrent ? runningCalls : [], runningCalls,
compactionRequests: visibleCompactionRequests,
requestAttempts: visibleRequestAttempts,
promptChanges: visiblePromptChanges,
callSchemas, callSchemas,
codeDispatches, codeDispatches,
}), }),
[ [
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches, selectedNodes, partial, runningCalls, visibleCompactionRequests,
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
], ],
) )
const collapsibleTurnIds = useMemo( const collapsibleTurnIds = useMemo(
() => turns () => 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), .map(turn => turn.turn),
[turns], [turns],
) )
@@ -198,68 +309,50 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index)) && collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => { const toggleTurn = (turn: number) => {
setCollapsedTurnsByContext((current) => { setCollapsedTurns((current) => {
const next = new Map(current) const collapsed = new Set(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (collapsed.has(turn)) collapsed.delete(turn) if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn) else collapsed.add(turn)
next.set(selectedContext.id, collapsed) return collapsed
return next
}) })
} }
const toggleAllTurns = () => { const toggleAllTurns = () => {
setCollapsedTurnsByContext((current) => { setCollapsedTurns((current) => {
const next = new Map(current) const collapsed = new Set(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (allTurnsCollapsed) { if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) collapsed.delete(turn) for (const turn of collapsibleTurnIds) collapsed.delete(turn)
} else { } else {
for (const turn of collapsibleTurnIds) collapsed.add(turn) for (const turn of collapsibleTurnIds) collapsed.add(turn)
} }
next.set(selectedContext.id, collapsed) return collapsed
return next
}) })
} }
const toggleAssistant = (index: number) => { const toggleAssistant = (index: number) => {
setCollapsedAssistantsByContext((current) => { setCollapsedAssistants((current) => {
const next = new Map(current) const collapsed = new Set(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (collapsed.has(index)) collapsed.delete(index) if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index) else collapsed.add(index)
next.set(selectedContext.id, collapsed) return collapsed
return next
}) })
} }
const toggleAllAssistants = () => { const toggleAllAssistants = () => {
setCollapsedAssistantsByContext((current) => { setCollapsedAssistants((current) => {
const next = new Map(current) const collapsed = new Set(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
if (allAssistantsCollapsed) { if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) collapsed.delete(index) for (const index of collapsibleAssistantIds) collapsed.delete(index)
} else { } else {
for (const index of collapsibleAssistantIds) collapsed.add(index) for (const index of collapsibleAssistantIds) collapsed.add(index)
} }
next.set(selectedContext.id, collapsed) return collapsed
return next
}) })
} }
const selectContext = (id: number) => {
setSelectedContextId(id === currentContext.id ? null : id)
}
return ( return (
<div className={css.root}> <div className={css.root}>
<TrajectoryToolbar <TrajectoryToolbar
{...contexts.length > 1
? {
contextLabel: contextLabel(selectedContext),
contextCurrent: viewingCurrent,
}
: {}}
collapsibleTurns={collapsibleTurnIds.length} collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed} allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns} onToggleAllTurns={toggleAllTurns}
@@ -267,27 +360,16 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
allAssistantsCollapsed={allAssistantsCollapsed} allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants} onToggleAllAssistants={toggleAllAssistants}
/> />
<div className={css.contextLayout}> <div className={css.ledger}>
{contexts.length > 1 && ( <TrajectoryTable
<ContextsPanel key={currentBranch.id}
contexts={contexts} requestNumbers={requestNumbers}
selectedId={selectedContext.id} turns={turns}
currentId={currentContext.id} collapsedTurns={collapsedTurns}
onSelect={selectContext} onToggleTurn={toggleTurn}
/> collapsedAssistants={collapsedAssistants}
)} onToggleAssistant={toggleAssistant}
<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> </div>
</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, AssistantBlock,
AssistantMessageNode, AssistantMessageNode,
CodeSubCall, CodeSubCall,
CompactionRequestView,
ConversationPromptChange,
ConversationSnapshot, ConversationSnapshot,
ModelRequestView,
ToolResultNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import type { import type {
@@ -32,6 +35,9 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes'] nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial'] partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls'] runningCalls: ConversationSnapshot['runningCalls']
compactionRequests?: readonly CompactionRequestView[]
requestAttempts?: readonly ModelRequestView[]
promptChanges?: readonly ConversationPromptChange[]
callSchemas?: ConversationSnapshot['callSchemas'] callSchemas?: ConversationSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */ /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches'] codeDispatches: ConversationSnapshot['codeDispatches']
@@ -62,13 +68,46 @@ interface TurnBucket {
groups: LaidGroup[] 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. * Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls. * @param input - nodes plus in-flight partial/runningCalls.
* @returns turns ordered by first appearance. * @returns turns ordered by first appearance.
*/ */
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { 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 resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>() const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) { for (const result of resultByCall.values()) {
@@ -114,10 +153,136 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
groups.push({ title, laid: [...laid] }) groups.push({ title, laid: [...laid] })
} }
for (let i = 0; i < nodes.length; i++) { const representedRequests = new Set<string>()
const node = nodes[i] for (const node of nodes) {
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ if (node.kind === 'assistant' && node.step > 0) {
if (node === undefined) continue 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') { if (node.kind === 'user' || node.kind === 'steering') {
// user/message has no turn on the wire; enclose it in the next assistant // 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. // (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), absTime: finiteTime(node.time),
cell: { cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content), index: ++index, kind: 'user', text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
opensTurn: node.kind === 'user', opensTurn: node.kind === 'user',
inputDetail: detailContent(node.content), inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)), sourceBlocks: node.content.map(block => sourceBlock(block)),
@@ -159,6 +327,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index, index: ++index,
kind: 'context', kind: 'context',
text: summarizeContent(node.content), text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
inputDetail: detailContent(node.content), inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)), sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0, timeSeconds: 0,
@@ -178,6 +349,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: { cell: {
index: ++index, index: ++index,
kind: 'tool', kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw) ? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node), : summarizeResult(node),
@@ -241,7 +413,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
laidList.push(laid) laidList.push(laid)
index = laid.cell.index 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. // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -365,6 +538,7 @@ function expandAssistant(
const message: TrajectoryCellProps = { const message: TrajectoryCellProps = {
index: ++index, index: ++index,
kind: 'message', kind: 'message',
sourceSeq: node.seq,
text: messageText !== '' text: messageText !== ''
? summarizeText(messageText) ? summarizeText(messageText)
: thinkingText !== '' : thinkingText !== ''
@@ -432,6 +606,13 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
return '' 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 { function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
switch (block.kind) { switch (block.kind) {
case 'text': return { type: 'text', content: block.text } case 'text': return { type: 'text', content: block.text }
@@ -524,6 +705,17 @@ function enclosingUserTurn(
return 1 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. */ /** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return if (usage === undefined) return
@@ -641,7 +833,7 @@ function summarizeResult(node: ToolResultNode): string {
return summarizeText(block.text) return summarizeText(block.text)
} }
} }
return node.call?.name ?? node.callId return 'No output'
} }
function detailResult(node: ToolResultNode): string { function detailResult(node: ToolResultNode): string {
@@ -655,6 +847,11 @@ function detailResult(node: ToolResultNode): string {
.map(block => block.type === 'text' ? block.text : '') .map(block => block.type === 'text' ? block.text : '')
.join('\n') .join('\n')
if (text !== '') return text 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) return JSON.stringify(node.content, null, 2)
} }
@@ -665,6 +862,13 @@ function detailContent(content: readonly { type: string; text?: string }[]): str
.join('\n') .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 { function summarizeContent(content: readonly { type: string; text?: string }[]): string {
for (const block of content) { for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) 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. */ /** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react' import type { HTMLAttributes } from 'react'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Closed set of trajectory record kinds. */ /** 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. */ /** Recorded inputs needed to derive assistant TTFT and decode throughput. */
export interface AssistantMetricDetail { export interface AssistantMetricDetail {
@@ -34,8 +42,20 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
text: string text: string
/** Whether this user record opens a new model turn. */ /** Whether this user record opens a new model turn. */
opensTurn?: boolean 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. */ /** Full request/message content for the details panel. */
inputDetail?: string 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. */ /** Full assistant/tool result content for the details panel. */
outputDetail?: string outputDetail?: string
/** Full assistant reasoning content for the details panel. */ /** 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. */ /* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
.root { .root {
--dsh-trajectory-toolbar-height: 48px; --dsh-trajectory-toolbar-height: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -23,14 +23,6 @@
font: var(--dsw-font-xs-13); font: var(--dsw-font-xs-13);
} }
.contextLayout {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.ledger { .ledger {
display: flex; display: flex;
flex: 1; flex: 1;

View File

@@ -125,7 +125,9 @@ export async function compactSurfaceRegion(
} }
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const summarizationInput = buildSummarizationInput(session, shadowedSeqs) const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal) const {
summary, rawOutput, provider, model, maxTokens, usage,
} = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session) const currentMeasurement = dependencies.meter.measure(session)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) { if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
@@ -144,12 +146,14 @@ export async function compactSurfaceRegion(
const summaryEvent = session.append('compact/summary', { const summaryEvent = session.append('compact/summary', {
summary, summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end }, shadowedRange: { start, end },
shadowedSeqs, shadowedSeqs,
shadowedTokenCount, shadowedTokenCount,
provider, provider,
model, model,
...maxTokens === undefined ? {} : { maxTokens }, ...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
}) })
session.append('user/message', { session.append('user/message', {
content: framedSummary, content: framedSummary,
@@ -164,6 +168,7 @@ export async function compactSurfaceRegion(
summarySeq: summaryEvent.seq, summarySeq: summaryEvent.seq,
endSeq: endEvent.seq, endSeq: endEvent.seq,
summary, summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end }, shadowedRange: { start, end },
shadowedSeqs, shadowedSeqs,
shadowedTokenCount, shadowedTokenCount,

View File

@@ -6,7 +6,9 @@
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { BlockAssembler } from '@deepseek-ai/dsh-llm' import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type {
ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema,
} from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
interface SummaryConfig { interface SummaryConfig {
@@ -85,9 +87,13 @@ export interface SummarizationInput {
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult { export interface SummaryResult {
summary: ContentBlock[] summary: ContentBlock[]
/** Complete provider output before the text-only summary projection. */
rawOutput?: ContentBlock[]
provider: string provider: string
model: string model: string
maxTokens?: number maxTokens?: number
/** Provider-reported usage for this summarization request. */
usage?: TokenUsage
} }
/** /**
@@ -145,15 +151,18 @@ export async function summarizeWithLlm(
const error = finishError(assembler.finish) const error = finishError(assembler.finish)
if (error !== undefined) throw error if (error !== undefined) throw error
const summary = textOnly(assembler.message().content) const rawOutput = assembler.message().content
const summary = textOnly(rawOutput)
if (!summary.some(block => block.text.trim().length > 0)) { if (!summary.some(block => block.text.trim().length > 0)) {
throw new Error('summarization produced no text summary content') throw new Error('summarization produced no text summary content')
} }
return { return {
summary, summary,
rawOutput,
provider: options.provider, provider: options.provider,
model: options.model, model: options.model,
maxTokens: config.maxTokens, maxTokens: config.maxTokens,
...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
} }
} }

View File

@@ -7,7 +7,7 @@
* @module @deepseek-ai/dsh-compact/types * @module @deepseek-ai/dsh-compact/types
*/ */
import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-session' { declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap { interface SessionEventMap {
@@ -21,6 +21,8 @@ declare module '@deepseek-ai/dsh-session' {
*/ */
'compact/summary': { 'compact/summary': {
summary: ContentBlock[] summary: ContentBlock[]
/** Complete provider output before the backend's safe summary projection. */
rawOutput?: ContentBlock[]
shadowedRange: { start: number; end: number } shadowedRange: { start: number; end: number }
shadowedSeqs: number[] shadowedSeqs: number[]
shadowedTokenCount: number shadowedTokenCount: number
@@ -35,6 +37,8 @@ declare module '@deepseek-ai/dsh-session' {
model: string model: string
/** The generation cap the summarize call sent, when one applied. */ /** The generation cap the summarize call sent, when one applied. */
maxTokens?: number maxTokens?: number
/** Provider-reported token usage for the summarization request, when emitted. */
usage?: TokenUsage
} }
/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ /** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
'compact/end': { turn: number; error?: string } 'compact/end': { turn: number; error?: string }
@@ -51,6 +55,8 @@ export interface CompactionResult {
endSeq: number endSeq: number
/** The summary content blocks produced by the backend. */ /** The summary content blocks produced by the backend. */
summary: ContentBlock[] summary: ContentBlock[]
/** Complete provider output before the backend's safe summary projection. */
rawOutput?: ContentBlock[]
/** /**
* The surface-boundary pair that was shadowed: the seqs of the first * The surface-boundary pair that was shadowed: the seqs of the first
* (`start`) and last (`end`) surface nodes of the replaced range. A * (`start`) and last (`end`) surface nodes of the replaced range. A

4
pnpm-lock.yaml generated
View File

@@ -1293,6 +1293,10 @@ importers:
version: 18.3.1 version: 18.3.1
packages/client/ui-trajectory: packages/client/ui-trajectory:
dependencies:
diff:
specifier: ^9.0.0
version: 9.0.0
devDependencies: devDependencies:
'@deepseek-ai/dsh-client-runtime': '@deepseek-ai/dsh-client-runtime':
specifier: workspace:^ specifier: workspace:^

View File

@@ -0,0 +1,47 @@
/**
* CSS Modules enter client bundles through virtual modules, so the loader must
* explicitly register the underlying stylesheet as a watch dependency.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { clientBundle } from '../packages/client/tsdown.client.ts'
interface CssPlugin {
name: string
resolveId?: (source: string, importer?: string) => string | null
load?: (this: { addWatchFile(id: string): void }, id: string) => Promise<string | null>
}
function cssPlugin(): CssPlugin {
const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js'])
const plugins = (configs[1] as { plugins: CssPlugin[] }).plugins
const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline')
if (plugin === undefined) throw new Error('CSS Modules plugin missing from client config')
return plugin
}
describe('client bundle CSS Modules', () => {
it('registers the source stylesheet as a watch dependency', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-client-css-watch-'))
try {
const stylesheet = join(root, 'Fixture.module.css')
const importer = join(root, 'index.ts')
await writeFile(stylesheet, '.root { color: red; }\n')
const plugin = cssPlugin()
const virtualId = plugin.resolveId?.('./Fixture.module.css', importer)
if (typeof virtualId !== 'string' || plugin.load === undefined) {
throw new Error('CSS Modules plugin hooks are incomplete')
}
const watched: string[] = []
const output = await plugin.load.call({ addWatchFile: id => watched.push(id) }, virtualId)
expect(watched).toEqual([stylesheet])
expect(output).toContain('data-plugin-css')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})