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
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .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.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c
2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7
2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99
@@ -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.**
- 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.
- 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.
- 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.
- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 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.
- 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
@@ -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.
**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.
@@ -33,4 +34,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
## 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.
@@ -12,12 +12,13 @@ Status: implemented
**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。**
- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进
- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈
- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义
- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影
- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文
- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭
- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级
- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。
## 曾考虑的替代方案
@@ -25,7 +26,7 @@ Status: implemented
**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。
**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记
**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
@@ -33,4 +34,4 @@ Status: implemented
## 后果
轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器
+2 -1
View File
@@ -30,7 +30,8 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
CompactionRequestView, ConversationContext, ConversationContextOriginKind,
ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
@@ -51,6 +51,7 @@ export interface UserMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** Recorded boundaries used to derive assistant latency and throughput. */
@@ -67,6 +68,7 @@ export interface AssistantTiming {
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
@@ -108,6 +110,7 @@ export interface SteeringMessageNode {
turn: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A context/system injection surfaced in the flow. */
@@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot {
tools: readonly ToolSchema[]
}
/** One system-prompt/tool-catalog state that became effective in the request timeline. */
export interface ConversationPromptChange {
/** Sequence of the request/header event that introduced this state. */
seq: number
/** Unix epoch ms from the request/header event. */
time: number
/** How the model-visible system configuration differs from the prior recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** Complete state effective from this event onward. */
prompt: ConversationPromptSnapshot
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
@@ -259,6 +276,46 @@ export interface ConversationContext {
nodes: readonly ConversationNode[]
}
/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */
export interface CompactionRequestView {
startSeq: number
turn: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
summarySeq?: number
replacementSeq?: number
summary?: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
}
/** One ordinary provider-request attempt reconstructed from a durable step boundary. */
export interface ModelRequestView {
/** Sequence of the step/start event that opened this attempt. */
startSeq: number
turn: number
step: number
/** Unix epoch ms from step/start. */
startedAt: number
/** Assistant completion time, or the failed step/end time when no response completed. */
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Assistant/message sequence when this attempt completed successfully. */
resultSeq?: number
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Retry ordinal scheduled after this failed attempt. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
@@ -272,6 +329,12 @@ export interface ConversationSnapshot {
nodes: readonly ConversationNode[]
/** Append-only context generations split at every model-surface replacement. */
contexts?: readonly ConversationContext[]
/** Auxiliary compaction requests, including those without an assistant/message surface node. */
compactionRequests?: readonly CompactionRequestView[]
/** Ordinary provider requests, including failed attempts that produced no assistant message. */
requestAttempts?: readonly ModelRequestView[]
/** System-prompt/tool-catalog changes in request order. */
promptChanges?: readonly ConversationPromptChange[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
@@ -59,6 +59,7 @@ function materializeNode(
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'assistant/message':
return {
@@ -76,6 +77,7 @@ function materializeNode(
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
@@ -12,8 +12,9 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode,
ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot,
ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private callSchemas = new Map<string, ToolSchema>()
private callSchemasRev = 0
private callSchemasCache: { rev: number; value: ReadonlyMap<string, ToolSchema> } | null = null
private promptChangesRev = 0
private promptChangesCache: {
rev: number
value: readonly ConversationPromptChange[]
} | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
switch (event.type) {
case 'request/header': {
this.promptChangesRev++
this.activeToolSchemas = new Map(
(event.data.header.tools ?? []).map(schema => [schema.name, schema]),
)
@@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.activeToolSchemas = new Map()
this.callSchemas = new Map()
this.callSchemasRev++
this.promptChangesRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
if (
this.promptChangesCache === null
|| this.promptChangesCache.rev !== this.promptChangesRev
) {
this.promptChangesCache = {
rev: this.promptChangesRev,
value: derivePromptChanges(this.events),
}
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
contexts,
compactionRequests: deriveCompactionRequests(this.events),
requestAttempts: deriveModelRequests(this.events),
promptChanges: this.promptChangesCache.value,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
@@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
function modelRequestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
/** Project every durable step into one provider request, retaining failed retry attempts. */
function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] {
const requests: ModelRequestView[] = []
const byStep = new Map<string, number>()
let activeStep: string | undefined
let activeConfig: ConversationPromptSnapshot['config']
const update = (key: string, change: Partial<ModelRequestView>): void => {
const index = byStep.get(key)
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'request/header') {
activeConfig = sourceEvent.data.header.config
if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig })
continue
}
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = modelRequestKey(turn, step)
byStep.set(key, requests.length)
requests.push({
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activeConfig === undefined ? {} : { requestConfig: activeConfig }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'assistant/message') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
update(key, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.provenance.provider,
model: sourceEvent.data.provenance.model,
},
...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = byStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request !== undefined && request.status === 'running') {
requests[index] = {
...request,
completedAt: sourceEvent.time,
status: 'error',
}
}
if (activeStep === key) activeStep = undefined
continue
}
if ((sourceEvent.type as string) === 'llm/retry') {
const event = sourceEvent as unknown as RetryEvent
update(modelRequestKey(event.data.turn, event.data.step), {
status: 'error',
error: event.data.failure.message,
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
})
continue
}
if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue
const reason = sourceEvent.data.reason
update(modelRequestKey(sourceEvent.data.turn, reason.step), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
}
return requests
}
interface CompactionStartEvent {
type: 'compact/start'
seq: number
time: number
data: { turn: number }
}
interface CompactionSummaryEvent {
type: 'compact/summary'
seq: number
time: number
data: {
summary: readonly ContentBlock[]
rawOutput?: readonly ContentBlock[]
provider: string
model: string
maxTokens?: number
usage?: unknown
}
}
interface CompactionEndEvent {
type: 'compact/end'
seq: number
time: number
data: { turn: number; error?: string }
}
/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */
function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] {
const requests: CompactionRequestView[] = []
let active: CompactionRequestView | undefined
for (const sourceEvent of events) {
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
active = {
startSeq: event.seq,
turn: event.data.turn,
startedAt: event.time,
completedAt: null,
status: 'running',
}
continue
}
if (type === 'compact/summary' && active !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
active = {
...active,
summarySeq: event.seq,
summary: event.data.summary,
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
provenance: {
provider: event.data.provider,
model: event.data.model,
},
requestConfig: {
provider: event.data.provider,
model: event.data.model,
purpose: 'compaction',
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
},
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
}
continue
}
if (
sourceEvent.type === 'user/message'
&& active?.summarySeq !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
active = { ...active, replacementSeq: sourceEvent.seq }
continue
}
if (type !== 'compact/end' || active === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
active = {
...active,
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
}
requests.push(active)
active = undefined
}
if (active !== undefined) requests.push(active)
return requests
}
/** Project request headers into model-visible system/tool changes only. */
function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] {
const changes: ConversationPromptChange[] = []
let previous: ConversationPromptSnapshot | undefined
for (const event of events) {
if (event.type !== 'request/header') continue
const prompt: ConversationPromptSnapshot = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
if (previous === undefined || systemChanged || toolsChanged) {
changes.push({
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
prompt,
...(previous === undefined ? {} : { previous }),
})
}
previous = prompt
}
return changes
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}
+2
View File
@@ -6,6 +6,7 @@
* lightningcss inside the bundle: importing `x.module.css` yields the
* hashed class map, and the css text auto-injects a <style data-plugin="<id>">
* tag at factory execution (the loader removes plugin-owned tags on unload).
* The virtual loader registers each real stylesheet as a watch dependency.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, resolve as resolvePath } from 'node:path'
@@ -127,6 +128,7 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
@@ -49,9 +49,7 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. */
.handle {
position: absolute;
z-index: 6;
@@ -75,36 +73,3 @@
transition: none;
}
}
.handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 32px;
border-radius: 10px;
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}
@@ -49,7 +49,7 @@
}
.expandedTopLevelContainer {
padding: 0 0 0 calc(2ch - 12px);
padding: 0;
}
.row.topLevelBracket {
@@ -59,7 +59,7 @@
.children {
margin: 0;
padding: 0 0 0 4px;
padding: 0;
list-style: none;
}
@@ -69,7 +69,7 @@
min-width: 100%;
min-height: 16px;
margin: 0;
padding: 0 0 0 12px;
padding: 0 0 0 10px;
list-style: none;
}
@@ -178,7 +178,7 @@
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
box-sizing: border-box;
width: 8px;
height: 16px;
@@ -196,7 +196,7 @@
border-left: 6px solid currentColor;
content: '';
transform: scale(0.75);
transform-origin: center;
transform-origin: 33.333% center;
}
.collapseIcon::before {
@@ -66,6 +66,17 @@
font: inherit;
}
.noLanguage .bannerWrap {
position: absolute;
right: 0;
background: transparent;
}
.noLanguage .banner {
padding-left: 8px;
background: transparent;
}
.block :where(pre) {
font: var(--dsl-code-block-content-font);
padding: 16px;
@@ -76,6 +87,10 @@
background: var(--dsw-alias-markdown-code-block);
}
.noLanguage :where(pre) {
padding-right: 72px;
}
/* Shiki inlines its theme background var; route it to the repo token. */
.block :where(pre.shiki) {
background: var(--dsw-alias-markdown-code-block) !important;
@@ -52,6 +52,7 @@ async function writeClipboard(text: string): Promise<boolean> {
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const hasLanguage = lang !== undefined && lang !== ''
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
@@ -80,7 +81,10 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div
ref={rootRef}
className={clsx(css.block, !hasLanguage && css.noLanguage, 'md-code-block', className)}
>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>
@@ -1,8 +1,7 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML is intentionally
* omitted, links keep their labels, images keep alt text, and code keeps its
* source text.
* Parsing shares the renderer's GFM grammar; raw HTML remains literal, links
* keep their labels, images keep alt text, and code keeps its source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
@@ -37,6 +36,7 @@ function inlineText(node: MarkdownNode): string {
case 'break':
return '\n'
case 'html':
return node.value ?? ''
case 'thematicBreak':
case 'definition':
return ''
@@ -70,6 +70,7 @@ function blockText(node: MarkdownNode): string {
case 'tableCell':
return compactInline(inlineText(node))
case 'html':
return node.value?.trim() ?? ''
case 'thematicBreak':
case 'definition':
return ''
@@ -33,6 +33,9 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"diff": "^9.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -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;
}
}
@@ -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>
)
}
@@ -46,6 +46,11 @@
white-space: nowrap;
}
.tagSystem {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.tagUser {
color: var(--dsw-alias-state-success-primary);
background: var(--dsw-alias-state-success-tertiary);
@@ -16,6 +16,7 @@ export type {
/** Display label per kind (matches the design tags). */
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'System',
user: 'User',
context: 'Context',
message: 'Message',
@@ -24,6 +25,7 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
}
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
system: css.tagSystem!,
user: css.tagUser!,
context: css.tagContext!,
message: css.tagMessage!,
@@ -41,6 +43,8 @@ export function TrajectoryCell({
kind,
text,
inputDetail: _inputDetail,
promptDetail: _promptDetail,
previousPromptDetail: _previousPromptDetail,
outputDetail: _outputDetail,
thinkingDetail: _thinkingDetail,
sourceBlocks: _sourceBlocks,
@@ -18,7 +18,7 @@
.table {
--trajectory-turn-accent: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 22%,
var(--dsw-static-blue-500) 22%,
var(--dsw-alias-bg-layer-1)
);
@@ -28,7 +28,7 @@
table-layout: fixed;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-xs-13);
font: var(--dsw-font-xxs-12);
}
.eventColumn {
@@ -50,7 +50,7 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-tertiary);
background: var(--dsw-specific-sidebar-fill);
font: var(--dsw-font-xs-13);
font: var(--dsw-font-xxs-12);
font-weight: 500;
text-align: left;
text-overflow: ellipsis;
@@ -83,6 +83,22 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.table tbody tr[data-request-only='true']:hover {
background: transparent;
}
.table tbody tr[data-request-only='true'] td {
height: 1px;
padding-top: 0;
padding-bottom: 0;
border-bottom: 0;
}
.table tbody tr[data-request-only='true'] .turnRail {
top: -15px;
bottom: 0;
}
.table tbody tr:not([data-collapsed-summary]):focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
}
@@ -95,7 +111,7 @@
position: absolute;
z-index: 6;
top: -8px;
left: 6px;
left: 2px;
width: 16px;
height: 16px;
padding: 0;
@@ -106,10 +122,10 @@
.requestBoundaryControl::before {
position: absolute;
top: 6px;
left: 6px;
width: 4px;
height: 4px;
top: 5.5px;
left: 5.5px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--dsw-alias-label-caption);
box-shadow:
@@ -118,7 +134,7 @@
content: '';
transition:
background 120ms var(--ds-ease-in-out),
transform 120ms var(--ds-ease-in-out);
box-shadow 120ms var(--ds-ease-in-out);
}
.requestBoundaryControl::after {
@@ -127,9 +143,11 @@
left: 17px;
width: max-content;
padding: 0 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 2px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
content: attr(data-label);
font: 9px/12px var(--ds-font-family-code);
opacity: 0;
@@ -143,10 +161,19 @@
}
.requestBoundaryControl:hover::before,
.requestBoundaryControl:focus-visible::before,
.requestBoundaryControl[aria-pressed='true']::before {
background: var(--dsw-alias-label-primary);
transform: scale(1.25);
.requestBoundaryControl:focus-visible::before {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControlActive::before,
.requestBoundaryControlActive:hover::before,
.requestBoundaryControlActive:focus-visible::before {
background: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%,
var(--dsw-alias-bg-layer-1)
);
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControl:hover::after,
@@ -219,14 +246,17 @@
}
.turnLabel {
position: absolute;
z-index: 3;
top: 0;
left: 0;
display: inline-flex;
flex: none;
align-items: center;
box-sizing: border-box;
width: max-content;
margin-left: 4px;
padding: 1px 2px;
border-radius: 2px;
padding: 1px 5px;
border-radius: 0 0 2px;
color: var(--dsw-alias-label-tertiary);
background: var(--dsw-alias-bg-module-platform);
font: 8px/10px var(--ds-font-family-code);
@@ -241,11 +271,7 @@
var(--dsw-static-blue-500) 55%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-static-blue-500) 10%,
var(--dsw-alias-bg-layer-1)
);
background: var(--trajectory-turn-accent);
}
.eventInner {
@@ -260,15 +286,8 @@
display: flex;
flex: none;
align-items: flex-end;
width: 76px;
}
.kindSlotLeft {
justify-content: flex-start;
}
.kindSlotRight {
justify-content: flex-end;
width: 76px;
}
.content {
@@ -316,6 +335,80 @@
background: var(--dsw-alias-state-success-tertiary);
}
.compacted {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-module-platform);
}
.compactedSummary {
margin-top: 12px;
padding: 0 0 14px;
font: var(--dsw-font-xs-13);
}
.promptDiffSections {
display: flex;
max-height: 100%;
flex-direction: column;
padding: 10px 14px 14px;
gap: 14px;
overflow: auto;
}
.promptDiffSection {
min-width: 0;
}
.promptDiffTitle {
margin: 0 0 6px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}
.promptDiff {
margin: 0;
overflow: auto;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: 11px/17px var(--ds-font-family-code);
white-space: pre;
}
.promptDiff span {
display: block;
min-width: max-content;
padding: 0 6px;
}
.promptDiffLinemeta {
color: var(--dsw-alias-label-caption);
background: var(--dsw-alias-bg-module-platform);
user-select: none;
}
.promptDiffLinecontext {
color: var(--dsw-alias-label-secondary);
}
.promptDiffLineadded {
color: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 72%,
var(--dsw-alias-label-primary)
);
background: var(--dsw-alias-state-success-tertiary);
}
.promptDiffLineremoved {
color: var(--dsw-alias-state-error-primary);
background: color-mix(
in srgb,
var(--dsw-alias-state-error-primary) 12%,
var(--dsw-alias-bg-layer-1)
);
}
.assistantVioletBright {
color: color-mix(
in srgb,
@@ -359,8 +452,9 @@
white-space: nowrap;
}
.table tbody tr[data-collapsed-summary] td {
height: 24px;
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
}
.table tbody tr[data-collapsed-summary] {
@@ -383,7 +477,7 @@
min-width: 0;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
line-height: 16px;
}
.collapsedTurnEllipsis {
@@ -425,7 +519,7 @@
.toolCallNameTypeface {
color: var(--dsw-alias-label-primary);
font: 400 13px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
font: 400 12px/18px Menlo, Consolas, 'Liberation Mono', 'PingFang SC',
'Microsoft YaHei';
}
@@ -454,6 +548,10 @@
color: var(--dsw-alias-label-secondary);
}
.noOutputText {
color: var(--dsw-alias-label-caption);
}
.arrow {
flex: none;
margin-right: 8px;
@@ -492,38 +590,6 @@
user-select: none;
}
.detailsResizeHandle::after {
position: absolute;
top: 50%;
left: 50%;
box-sizing: border-box;
width: 12px;
height: 32px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-1);
content: '';
opacity: 0;
transform: translate(-50%, -50%);
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.details:hover .detailsResizeHandle::after,
.detailsResizeHandle:hover::after,
.detailsResizeHandle:active::after,
.detailsResizeHandle:focus-visible::after {
opacity: 1;
}
.detailsResizeHandle:hover::after,
.detailsResizeHandle:active::after,
.detailsResizeHandle:focus-visible::after {
border-color: var(--dsw-alias-border-l3);
background: var(--dsw-alias-bg-module-platform);
}
.detailsResizeHandle:focus-visible {
outline: none;
}
@@ -645,6 +711,28 @@
overflow: auto;
}
.detailBodySummary {
display: flex;
box-sizing: border-box;
flex-direction: column;
padding-bottom: 12px;
overflow: hidden;
}
.detailBodySummary > .overview {
flex: none;
}
.detailBodySummary > .compactedSummary {
flex: 1;
min-height: 0;
overflow: auto;
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
.overview {
margin: 0;
padding: 8px 0;
@@ -733,6 +821,20 @@
white-space: nowrap;
}
.timestampToggle {
all: unset;
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}
.timestampToggle:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.tokenEquation {
display: inline-flex;
align-items: baseline;
@@ -744,15 +846,34 @@
}
.overviewSections {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
border-top: 0;
}
.overviewSection {
display: flex;
flex: 0 1 auto;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.overviewSection:has(> .overviewPreview > .overview),
.overviewSection:has(> .overviewPreview > .noPayload) {
flex-shrink: 0;
}
.overviewSection + .overviewSection {
padding-top: 8px;
}
.overviewHeading {
display: flex;
flex: none;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
@@ -801,7 +922,8 @@
}
.overviewPreview {
max-height: 180px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
background: var(--dsw-alias-bg-layer-1);
@@ -865,6 +987,42 @@
font: var(--dsw-font-xs-13);
}
.markdownPreview > div > :first-child {
margin-top: 0;
}
.markdownPreview > div > :last-child {
margin-bottom: 0;
}
.markdownPreview > div :where(h1, h2, h3) {
margin: 12px 0 6px;
}
.markdownPreview > div :where(h4, h5, h6) {
margin: 10px 0 5px;
}
.markdownPreview > div :where(p, ul, ol) {
margin: 8px 0;
}
.markdownPreview > div li:not(:first-child) {
margin-top: 3px;
}
.markdownPreview > div blockquote {
margin-top: 8px;
}
.markdownPreview > div hr {
margin: 14px 0;
}
.markdownPreview > div > :global(.md-code-block) {
margin: 10px 0;
}
.assistantContent .markdownPayload,
.assistantContent .payload {
min-height: 0;
@@ -1130,11 +1288,12 @@
all: unset;
display: flex;
box-sizing: border-box;
width: 100%;
width: calc(100% + 4px);
min-width: 0;
height: 19px;
align-items: center;
padding: 0 4px 0 0;
margin-left: -4px;
padding: 0 4px;
border-radius: 3px;
cursor: pointer;
}
@@ -5,6 +5,7 @@ import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
AssistantRequestConfig, ConversationPromptSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -16,8 +17,10 @@ import type { TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
user: 'USER',
context: 'CONTEXT',
compacted: 'COMPACTED',
message: 'ASSISTANT',
tool: 'TOOL',
subtool: 'SUBTOOL',
@@ -40,12 +43,14 @@ type DetailTab =
| 'overview'
| 'rendered'
| 'source'
| 'origin'
| 'input'
| 'output'
| 'schema'
| 'options'
| 'usage'
| 'timing'
| 'diff'
type RecordState = 'complete' | 'running' | 'error'
interface DetailTabItem {
@@ -86,11 +91,14 @@ const TOOL_REQUEST_MIN_WIDTH = 180
const TOOL_REQUEST_MAX_WIDTH = 480
const DEFAULT_TOOL_REQUEST_SHARE = 0.36
const DEFAULT_TOOL_REQUEST_OFFSET = 56
const SYSTEM_PROMPT_INDEX = 0
const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
{ id: 'system-prompt', label: 'System Prompt' },
{ id: 'tools', label: 'Tools' },
]
const SYSTEM_UPDATE_TABS: readonly DetailTabItem[] = [
{ id: 'diff', label: 'Diff' },
...SYSTEM_PROMPT_TABS,
]
const REQUEST_TABS: readonly DetailTabItem[] = [
{ id: 'overview', label: 'Summary' },
{ id: 'options', label: 'Options' },
@@ -125,19 +133,40 @@ function formatDurationMs(milliseconds: number): string {
return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)} s`
}
function formatStartedAt(timestamp: number | null): { label: string; title?: string } {
if (timestamp === null || !Number.isFinite(timestamp)) return { label: 'Not available' }
function formatStartedAt(timestamp: number | null): string {
if (timestamp === null || !Number.isFinite(timestamp)) return 'Not available'
const date = new Date(timestamp)
const two = (value: number) => String(value).padStart(2, '0')
const three = (value: number) => String(value).padStart(3, '0')
const time = `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}.${three(date.getMilliseconds())}`
const day = `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`
return { label: time, title: `${day} ${time}` }
return `${day} ${time}`
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const formatted = formatStartedAt(timestamp)
return <dd title={formatted.title}>{formatted.label}</dd>
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
return (
<dd>
<button
type="button"
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
setShowUnix(current => !current)
}}
>
{showUnix ? (timestamp / 1_000).toFixed(3) : formatStartedAt(timestamp)}
</button>
</dd>
)
}
function totalTime(metrics: AssistantMetricDetail): string {
@@ -184,8 +213,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
/** Props for the trajectory ledger. */
export interface TrajectoryTableProps {
/** Latest model request header in force for the selected context. */
prompt?: ConversationPromptSnapshot
/** Session-global request numbers for the request groups visible in this context. */
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
@@ -200,11 +227,23 @@ export interface TrajectoryTableProps {
onToggleAssistant(index: number): void
}
/** One context-local request identity paired with its session-global number. */
/** One request identity paired with its session-global number. */
export interface TrajectoryRequestNumber {
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
seq?: number
turn: number
step: number
group: string
number: number
purpose?: 'compaction'
status?: 'complete' | 'running' | 'error'
startedAt?: number
completedAt?: number | null
error?: string
retry?: number
maxRetries?: number
retryDelayMs?: number
resultSeq?: number
provider?: string
model?: string
requestConfig?: AssistantRequestConfig
@@ -226,7 +265,10 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
let firstInTurn = true
const records = turn.groups.flatMap((group) => {
return group.cells.map((cell, index) => {
const turnStart = firstInTurn && index === 0
const turnStart = firstInTurn
&& cell.requestOnly !== true
&& cell.kind !== 'system'
&& cell.kind !== 'compacted'
if (turnStart) firstInTurn = false
return {
turn: turn.turn,
@@ -260,7 +302,7 @@ function indexRequestNumbers(
): ReadonlyMap<string, number> {
const numbers = new Map<string, number>()
for (const request of sessionNumbers ?? []) {
numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number)
numbers.set(requestKey(request.turn, request.group), request.number)
}
let next = Math.max(0, ...numbers.values()) + 1
const boundaries = records
@@ -274,14 +316,6 @@ function indexRequestNumbers(
}
function summarizeTurn(records: readonly TableRecord[]): string {
const userText = records
.filter(record => record.cell.kind === 'user')
.map(record => recordDisplayText(record.cell))
.find(text => text !== '')
const assistantText = records
.filter(record => record.cell.kind === 'message')
.map(record => recordDisplayText(record.cell))
.find(text => text !== '')
const steps = new Set(
records
.map(record => record.group)
@@ -290,14 +324,10 @@ function summarizeTurn(records: readonly TableRecord[]): string {
const toolCalls = records.filter(record =>
record.cell.kind === 'tool' || record.cell.kind === 'subtool',
).length
const parts: string[] = []
const message = userText ?? assistantText
if (message !== undefined) parts.push(message)
parts.push(
return [
`${steps} ${steps === 1 ? 'step' : 'steps'}`,
`${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`,
)
return parts.join(' · ')
].join(' · ')
}
function collapseTurnRecords(
@@ -314,8 +344,11 @@ function collapseTurnRecords(
return records.flatMap((record) => {
if (!collapsedTurns.has(record.turn)) return [record]
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
if (turnRecords.length <= 1) return [record]
if (!record.turnStart) return []
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
const contentRecords = turnRecords.filter(candidate =>
candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system')
if (contentRecords.length <= 1) return [record]
if (record.cell.index !== contentRecords[0]?.cell.index) return []
return [
{ ...record, turnEnd: false },
{
@@ -323,7 +356,7 @@ function collapseTurnRecords(
groupStart: false,
turnStart: false,
turnEnd: true,
collapsedSummary: summarizeTurn(turnRecords.slice(1)),
collapsedSummary: summarizeTurn(contentRecords.slice(1)),
collapsedSummaryKind: 'turn',
},
]
@@ -395,6 +428,7 @@ function collapseAssistantRecords(
function stateOf(record: TableRecord): RecordState {
if (record.cell.isError) return 'error'
if (record.cell.kind === 'compacted' && record.cell.timeSeconds === null) return 'running'
if (
(record.cell.kind === 'tool' || record.cell.kind === 'subtool')
&& record.cell.outputDetail === undefined
@@ -437,6 +471,9 @@ function inputTotal(usage: TrajectoryUsage): number | undefined {
function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
if (usage === undefined) return <p className={css.noPayload}>Usage not reported</p>
const totalInput = inputTotal(usage)
const otherOutput = usage.output !== undefined && usage.reasoning !== undefined
? usage.output - usage.reasoning
: undefined
return (
<dl className={css.overview}>
{totalInput !== undefined && (
@@ -469,6 +506,12 @@ function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) {
<dd>{usage.reasoning} tok</dd>
</div>
)}
{otherOutput !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Content</dt>
<dd>{otherOutput} tok</dd>
</div>
)}
</dl>
)
}
@@ -513,6 +556,46 @@ function RequestOptions({
)
}
function messageOriginLabel(source: unknown): string {
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
return 'Unknown'
}
const kind = Reflect.get(source, 'kind')
if (kind === 'user') return 'User'
if (kind === 'plugin') {
const plugin = Reflect.get(source, 'plugin')
return typeof plugin === 'string' && plugin !== ''
? `Plugin · ${plugin}`
: 'Plugin'
}
if (kind === 'goal') {
const round = Reflect.get(source, 'round')
return typeof round === 'number' && round > 0
? `Goal · Round ${round}`
: 'Goal'
}
if (typeof kind !== 'string' || kind === '') return 'Unknown'
return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
}
function MessageOrigin({ record }: { record: TableRecord }) {
const source = record.cell.messageSource
if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
const sourceRoot = typeof source === 'object' && source !== null
? source
: { value: source }
const data = record.cell.messageMeta === undefined
? sourceRoot
: { source, meta: record.cell.messageMeta }
return (
<JsonTree
data={data}
label="Message origin JSON"
className={css.jsonPayload!}
/>
)
}
function isMarkdownRecord(record: TableRecord): boolean {
return record.cell.kind === 'user'
|| record.cell.kind === 'context'
@@ -557,16 +640,32 @@ function markdownSource(record: TableRecord): string | undefined {
if (record.cell.kind === 'user' || record.cell.kind === 'context') {
return record.cell.inputDetail
}
if (record.cell.kind === 'message') return record.cell.outputDetail
if (record.cell.kind === 'message' || record.cell.kind === 'compacted') {
return record.cell.outputDetail
}
return undefined
}
function detailTabs(record: TableRecord): readonly DetailTabItem[] {
if (record.cell.kind === 'system') {
return record.cell.previousPromptDetail === undefined
? SYSTEM_PROMPT_TABS
: SYSTEM_UPDATE_TABS
}
if (record.cell.kind === 'compacted') {
return [
{ id: 'overview', label: 'Summary' },
{ id: 'source', label: 'Raw Output' },
]
}
if (isMarkdownRecord(record)) {
return [
{ id: 'overview', label: 'Summary' },
{ id: 'rendered', label: 'Preview' },
{ id: 'source', label: 'Source' },
...(record.cell.messageSource === undefined
? []
: [{ id: 'origin', label: 'Origin' } as const]),
]
}
return [
@@ -586,8 +685,7 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
? cell.outputDetail ?? cell.thinkingDetail
: undefined
if (!markdown) return cell.text
const plainText = extractMarkdownPlainText(markdown)
return plainText.replace(/\s+/g, ' ').trim()
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
}
function toolCallTextParts(
@@ -824,6 +922,83 @@ function ToolCatalog({ tools }: { tools: ConversationPromptSnapshot['tools'] })
)
}
interface PromptDiffLine {
kind: 'meta' | 'context' | 'added' | 'removed'
text: string
}
function promptDiffLines(before: string, after: string): readonly PromptDiffLine[] {
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: 3 })
return patch.hunks.flatMap((hunk, hunkIndex) => [
...(hunkIndex === 0 ? [] : [{ kind: 'meta' as const, text: '' }]),
{
kind: 'meta' as const,
text: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
},
...hunk.lines.flatMap((line): PromptDiffLine[] => {
if (line.startsWith('\\')) return []
if (line.startsWith('+')) return [{ kind: 'added', text: line }]
if (line.startsWith('-')) return [{ kind: 'removed', text: line }]
return [{ kind: 'context', text: line }]
}),
])
}
function PromptDiffSection({
title,
before,
after,
}: {
title: string
before: string
after: string
}) {
const lines = promptDiffLines(before, after)
if (lines.length === 0) return null
return (
<section className={css.promptDiffSection}>
<h3 className={css.promptDiffTitle}>{title}</h3>
<pre className={css.promptDiff}>
{lines.map((line, index) => (
<span className={css[`promptDiffLine${line.kind}`]} key={index}>
{line.text || ' '}
{'\n'}
</span>
))}
</pre>
</section>
)
}
function SystemPromptDiff({
before,
after,
}: {
before: ConversationPromptSnapshot
after: ConversationPromptSnapshot
}) {
const toolsBefore = JSON.stringify(before.tools, null, 2)
const toolsAfter = JSON.stringify(after.tools, null, 2)
return (
<div className={css.promptDiffSections}>
{before.system !== after.system && (
<PromptDiffSection
title="System Prompt"
before={before.system}
after={after.system}
/>
)}
{toolsBefore !== toolsAfter && (
<PromptDiffSection
title="Tools"
before={toolsBefore}
after={toolsAfter}
/>
)}
</div>
)
}
function ToolOutputBlocks({
blocks,
preview,
@@ -862,7 +1037,7 @@ function MarkdownRecordContent({
if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) {
return <SourceBlocks blocks={record.cell.sourceBlocks} onOpenCall={onOpenCall} />
}
if (record.cell.kind === 'message' && record.cell.thinkingDetail) {
if (record.cell.thinkingDetail) {
if (!rendered) {
const source = [
record.cell.thinkingDetail,
@@ -961,11 +1136,28 @@ function RecordTiming({ record }: { record: TableRecord }) {
function RequestTiming({
assistant,
anchor,
request,
}: {
assistant: TableRecord | undefined
anchor: TableRecord | undefined
request?: TrajectoryRequestNumber
}) {
if (assistant !== undefined) return <RecordTiming record={assistant} />
if (request?.startedAt !== undefined) {
const duration = request.completedAt === null || request.completedAt === undefined
? null
: Math.max(0, (request.completedAt - request.startedAt) / 1000)
return (
<dl className={css.overview}>
<div><dt>Started</dt><StartedAtValue timestamp={request.startedAt} /></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(duration)}</dd></div>
<div>
<dt>Timing source</dt>
<dd>{duration === null ? 'Session timestamps (running)' : 'Session timestamps'}</dd>
</div>
</dl>
)
}
return (
<dl className={css.overview}>
<div>
@@ -992,7 +1184,11 @@ function RecordPayload({
: 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p>
if (direction === 'output' && record.cell.outputBlocks && record.cell.outputBlocks.length > 0) {
if (
direction === 'output'
&& record.cell.outputBlocks?.some(block =>
block.imageSrc !== undefined || block.content !== '') === true
) {
return (
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
@@ -1029,6 +1225,7 @@ function RecordPayload({
css.payload,
preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined,
value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{value}
@@ -1141,7 +1338,6 @@ function OverviewSection({
* @returns The ledger and an optional local record inspector.
*/
export function TrajectoryTable({
prompt,
requestNumbers: sessionRequestNumbers,
turns,
collapsedTurns,
@@ -1161,13 +1357,14 @@ export function TrajectoryTable({
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
const systemPromptPreview = prompt === undefined
? 'Request header not recorded'
: prompt.system === ''
? 'No system prompt'
: extractMarkdownPlainText(prompt.system).replace(/\s+/g, ' ').trim()
const promptSelected = selectedIndex === SYSTEM_PROMPT_INDEX
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
const selectedPreviousPrompt = selected?.cell.kind === 'system'
? selected.cell.previousPromptDetail
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestRecords = selectedRequest === null
? []
@@ -1179,23 +1376,27 @@ export function TrajectoryTable({
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
? 'running'
: selectedRequestAssistant === undefined
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
: selectedRequestInfo?.status
?? (selectedRequestAssistant?.cell.assistantMetrics?.completedTime === null
? 'running'
: 'complete'
: selectedRequestAssistant === undefined
&& selectedRequestRecords.some(record => stateOf(record) === 'running')
? 'running'
: 'complete')
const selectedRequestToolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'tool',
).length
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool',
).length
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
? selectedRequestAssistant
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
selectedRequestAssistant === undefined
? undefined
@@ -1223,9 +1424,7 @@ export function TrajectoryTable({
const activeTurn = selectedRequest?.turn ?? selected?.turn
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: promptSelected
? SYSTEM_PROMPT_TABS
: selected === undefined ? [] : detailTabs(selected)
: selected === undefined ? [] : detailTabs(selected)
const selectedParents: ParentRecords = selected === undefined
? {}
: parentRecords(allRecords, selected)
@@ -1260,15 +1459,10 @@ export function TrajectoryTable({
setSelectedRequest(null)
setSelectedIndex(index)
if (record === undefined) return
const available = new Set(detailTabs(record).map(tab => tab.id))
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? 'overview')
}
const selectSystemPrompt = () => {
setSelectedRequest(null)
setSelectedIndex(SYSTEM_PROMPT_INDEX)
activateTab('system-prompt')
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
}
const selectRequest = (
@@ -1311,36 +1505,6 @@ export function TrajectoryTable({
<col className={css.contentColumn} />
</colgroup>
<tbody>
<tr
tabIndex={0}
aria-label="System prompt and tool catalog"
aria-selected={promptSelected}
data-kind="system"
data-selected={promptSelected || undefined}
onClick={selectSystemPrompt}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
selectSystemPrompt()
}}
>
<td className={css.event}>
{promptSelected && <span className={css.selectionRail} aria-hidden="true" />}
<div className={css.eventInner}>
<span className={`${css.kindSlot} ${css.kindSlotLeft}`}>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
</span>
</div>
</td>
<td className={css.content}>
<span
className={css.contentText}
title={prompt?.system || systemPromptPreview}
>
{systemPromptPreview}
</span>
</td>
</tr>
{records.map((record) => {
const displayText = recordDisplayText(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
@@ -1348,20 +1512,38 @@ export function TrajectoryTable({
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
&& !isCollapsedSummary
&& !collapsedTurns.has(record.turn)
? requestNumbers.get(requestKey(record.turn, record.group))
: undefined
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.number === request
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
tabIndex={0}
tabIndex={isRequestOnly ? -1 : 0}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && selectedIndex === record.cell.index}
: isRequestOnly
? `Request ${request ?? ''}, compaction`
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
@@ -1369,14 +1551,16 @@ export function TrajectoryTable({
data-turn-end={record.turnEnd || undefined}
data-collapsed-summary={record.collapsedSummaryKind}
data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
onClick={isCollapsedSummary
onClick={isRequestOnly
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary) return
if (isCollapsedSummary || isRequestOnly) return
if (collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
@@ -1391,11 +1575,15 @@ export function TrajectoryTable({
return
}
if (!record.turnStart) return
if (allRecords.filter(candidate => candidate.turn === record.turn).length <= 1) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
&& candidate.cell.requestOnly !== true
&& candidate.cell.kind !== 'system').length <= 1) return
event.preventDefault()
onToggleTurn(record.turn)
}}
onKeyDown={(event) => {
if (isRequestOnly) return
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
@@ -1410,13 +1598,12 @@ export function TrajectoryTable({
{request !== undefined && (
<button
type="button"
className={css.requestBoundaryControl}
aria-label={`Request #${request}`}
aria-pressed={
selectedRequest?.turn === record.turn
&& selectedRequest.number === request
}
data-label={`Request #${request}`}
className={requestSelected
? `${css.requestBoundaryControl} ${css.requestBoundaryControlActive}`
: css.requestBoundaryControl}
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
onClick={(event) => {
event.stopPropagation()
selectRequest({
@@ -1428,26 +1615,35 @@ export function TrajectoryTable({
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && (
{activeTurn === record.turn && !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
<span className={css.selectionRail} aria-hidden="true" />
)}
{!isCollapsedSummary
&& !isRequestOnly
&& record.turnStart && (
<span
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
</span>
)}
<div className={css.eventInner}>
{!isCollapsedSummary && (
{!isCollapsedSummary && !isRequestOnly && (
<span
className={
record.cell.kind === 'user'
|| record.cell.kind === 'context'
|| record.cell.kind === 'message'
? `${css.kindSlot} ${css.kindSlotLeft}`
: `${css.kindSlot} ${css.kindSlotRight}`
}
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'context'
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
@@ -1459,21 +1655,14 @@ export function TrajectoryTable({
>
{KIND_LABEL[record.cell.kind]}
</span>
{record.turnStart && record.cell.opensTurn && (
<span
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
</span>
)}
</span>
)}
</div>
</td>
<td className={css.content}>
{record.collapsedSummary !== undefined
{isRequestOnly
? null
: record.collapsedSummary !== undefined
? (
<span className={css.collapsedTurnContent} title={record.collapsedSummary}>
<span className={css.collapsedTurnEllipsis}></span>
@@ -1508,7 +1697,12 @@ export function TrajectoryTable({
{record.cell.result !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}></span>
<span className={css.inlineResultText}>{record.cell.result}</span>
<span className={record.cell.result === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
</span>
</span>
)}
</span>
@@ -1611,18 +1805,27 @@ export function TrajectoryTable({
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
</span>
<span className={css.detailsLocation}>Turn {selectedRequest.turn}</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
</span>
</>
)
: promptSelected
? (
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<>
<span className={`${css.kindTag} ${css.systemNeutral}`}>SYSTEM</span>
<span className={css.detailsLocation}>{selected?.cell.text}</span>
</>
)
: selected !== undefined && (
<>
<span className={`${css.kindTag} ${
selected.cell.kind === 'context'
? css.contextGreen
: selected.cell.kind === 'compacted'
? css.compacted
: selected.cell.kind === 'tool'
? css.toolAmber
: selected.cell.kind === 'message'
@@ -1634,7 +1837,11 @@ export function TrajectoryTable({
>
{KIND_LABEL[selected.cell.kind]}
</span>
<span className={css.detailsLocation}>{`Turn ${selected.turn} · ${selected.group}`}</span>
<span className={css.detailsLocation}>
{selected.cell.kind === 'compacted'
? `Turn ${selected.turn}`
: `Turn ${selected.turn} · ${selected.group}`}
</span>
</>
)}
</div>
@@ -1668,7 +1875,9 @@ export function TrajectoryTable({
</div>
<div
id="trajectory-detail-panel"
className={css.detailBody}
className={activeTab === 'overview'
? `${css.detailBody} ${css.detailBodySummary}`
: css.detailBody}
role="tabpanel"
aria-labelledby={`trajectory-detail-${activeTab}`}
>
@@ -1681,6 +1890,12 @@ export function TrajectoryTable({
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
</div>
{selectedRequestInfo?.purpose === 'compaction' && (
<div>
<dt>Purpose</dt>
<dd>Compaction</dd>
</div>
)}
{(selectedRequestInfo?.provider
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
<div>
@@ -1711,7 +1926,30 @@ export function TrajectoryTable({
<dd>{selectedRequestSubtoolCalls}</dd>
</div>
)}
{selectedRequestAssistant !== undefined && (
{selectedRequestInfo?.error !== undefined && (
<div>
<dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd>
</div>
)}
{selectedRequestInfo?.retry !== undefined && (
<div>
<dt>Retry</dt>
<dd>
Scheduled {selectedRequestInfo.retry}
{selectedRequestInfo.maxRetries === undefined
? ''
: ` of ${selectedRequestInfo.maxRetries}`}
</dd>
</div>
)}
{selectedRequestInfo?.retryDelayMs !== undefined && (
<div>
<dt>Retry delay</dt>
<dd>{formatDurationMs(selectedRequestInfo.retryDelayMs)}</dd>
</div>
)}
{selectedRequestResult !== undefined && (
<div>
<dt>Result</dt>
<dd className={css.overviewParentLinks}>
@@ -1719,10 +1957,14 @@ export function TrajectoryTable({
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
openRecordSummary(selectedRequestAssistant)
openRecordSummary(selectedRequestResult)
}}
>
<span>Assistant Message</span>
<span>
{selectedRequestInfo?.purpose === 'compaction'
? 'Compacted'
: 'Assistant Message'}
</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -1745,6 +1987,7 @@ export function TrajectoryTable({
<RequestTiming
assistant={selectedRequestAssistant}
anchor={selectedRequestAnchor}
request={selectedRequestInfo}
/>
</OverviewSection>
</div>
@@ -1763,30 +2006,93 @@ export function TrajectoryTable({
<RequestTiming
assistant={selectedRequestAssistant}
anchor={selectedRequestAnchor}
request={selectedRequestInfo}
/>
)}
{promptSelected
&& selectedPreviousPrompt !== undefined
&& activeTab === 'diff' && (
<SystemPromptDiff
before={selectedPreviousPrompt}
after={selectedPrompt}
/>
)}
{promptSelected && activeTab === 'system-prompt' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: prompt.system === ''
selectedPrompt.system === ''
? <p className={css.noPayload}>No system prompt in this request</p>
: (
<div className={`${css.markdownPayload} ${css.systemPrompt}`}>
<MarkdownText text={prompt.system} />
<MarkdownText text={selectedPrompt.system} />
</div>
)
)}
{promptSelected && activeTab === 'tools' && (
prompt === undefined
? <p className={css.noPayload}>Request header not recorded</p>
: <ToolCatalog tools={prompt.tools} />
<ToolCatalog tools={selectedPrompt.tools} />
)}
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
{!promptSelected
&& selected?.cell.kind === 'compacted'
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>{tokenSummary(selected.cell)}</dd>
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<MarkdownRecordContent
record={selected}
rendered
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
</div>
)}
</>
)}
{!promptSelected
&& selected !== undefined
&& selected.cell.kind !== 'compacted'
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
<dd className={css.overviewParentLinks}>
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => { activateTab('origin') }}
>
<span>{messageOriginLabel(selected.cell.messageSource)}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
</dd>
</div>
)}
{hasSelectedHierarchy && (
<div>
<dt>Hierarchy</dt>
<dt>
{selectedAssistantRequestTarget !== undefined
? 'Origin'
: 'Hierarchy'}
</dt>
<dd className={css.overviewParentLinks}>
{selectedAssistantRequestTarget !== undefined && (
<button
@@ -1915,6 +2221,9 @@ export function TrajectoryTable({
onOpenCall={openCallSummary}
/>
)}
{!promptSelected && selected !== undefined && activeTab === 'origin' && (
<MessageOrigin record={selected} />
)}
{!promptSelected && selected !== undefined && activeTab === 'input' && (
<RecordPayload record={selected} direction="input" />
)}
@@ -32,36 +32,6 @@
font: var(--dsw-font-xs-strong-13);
}
.separator {
flex: none;
color: var(--dsw-alias-label-caption);
user-select: none;
}
.context {
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
text-overflow: ellipsis;
white-space: nowrap;
}
.contextCurrent,
.contextFrozen {
flex: none;
font: 10px/16px var(--ds-font-family-code);
user-select: none;
}
.contextCurrent {
color: var(--dsw-alias-state-business-primary);
}
.contextFrozen {
color: var(--dsw-alias-label-caption);
}
.actions {
display: flex;
flex: none;
@@ -74,7 +44,7 @@
flex: none;
align-items: center;
box-sizing: border-box;
height: 26px;
height: 24px;
padding: 0 7px;
gap: 6px;
border: 0;
@@ -3,10 +3,6 @@
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Selected context title when the session contains discontinuities. */
contextLabel?: string
/** Whether the selected context is the live tail. */
contextCurrent?: boolean
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
@@ -27,8 +23,6 @@ export interface TrajectoryToolbarProps {
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
contextLabel,
contextCurrent,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
@@ -41,15 +35,6 @@ export function TrajectoryToolbar({
<div className={css.inner}>
<div className={css.summary}>
<span className={css.title}>Trajectory</span>
{contextLabel !== undefined && (
<>
<span className={css.separator}>/</span>
<span className={css.context}>{contextLabel}</span>
<span className={contextCurrent ? css.contextCurrent : css.contextFrozen}>
{contextCurrent ? 'Current' : 'Frozen'}
</span>
</>
)}
</div>
<div className={css.actions}>
<button
@@ -3,9 +3,12 @@
import { useMemo, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
AssistantMessageNode, ConversationContext,
AssistantMessageNode, CompactionRequestView, ConversationContext,
ConversationPromptChange, ModelRequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ContextsPanel, contextLabel } from './ContextsPanel.tsx'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
@@ -16,6 +19,9 @@ import { deriveTrajectoryLayout } from './layout.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
interface UsageLike {
inputTokens?: number
@@ -61,22 +67,21 @@ function addUsage(
}
}
function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext {
const context = contexts.at(-1)
if (context === undefined) throw new Error('trajectory context projection must not be empty')
return context
}
export function TrajectoryView({ useSession }: ConvViewProps) {
const [selectedContextId, setSelectedContextId] = useState<number | null>(null)
const [collapsedTurnsByContext, setCollapsedTurnsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const [collapsedAssistantsByContext, setCollapsedAssistantsByContext] = useState<
ReadonlyMap<number, ReadonlySet<number>>
>(() => new Map())
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const nodes = useSession((s) => s.nodes)
const projectedContexts = useSession((s) => s.contexts)
const compactionRequests = useSession(
(s) => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
)
const requestAttempts = useSession(
(s) => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
)
const promptChanges = useSession(
(s) => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
)
const partial = useSession((s) => s.partial)
const runningCalls = useSession((s) => s.runningCalls)
const callSchemas = useSession((s) => s.callSchemas)
@@ -87,95 +92,201 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
: projectedContexts,
[nodes, projectedContexts],
)
const currentContext = currentContextOf(contexts)
const selectedContext = selectedContextId === null
? currentContext
: contexts.find(context => context.id === selectedContextId) ?? currentContext
const viewingCurrent = selectedContext.id === currentContext.id
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestsBySeq = new Map<number, AssistantMessageNode>()
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
[contexts],
)
const currentBranch = branches.at(-1)
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = currentBranch.nodes
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
requestsBySeq.set(node.seq, node)
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
requestsBySeq.set(node.seq, node)
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const orderedRequests = [...requestsBySeq.values()]
.sort((left, right) => left.seq - right.seq)
const requestBySeq = new Map<number, TrajectoryRequestNumber>()
const attemptsByStep = new Map(
requestAttempts.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requestAttempts.map(request => ({
seq: request.startSeq,
kind: 'ordinary' as const,
request,
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
attemptsByStep.has(key)
? []
: [{
seq: node.seq,
kind: 'ordinary' as const,
request: undefined,
node,
}],
),
...compactionRequests.map(request => ({
seq: request.startSeq,
kind: 'compaction' as const,
request,
node: undefined,
})),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, node] of orderedRequests.entries()) {
const usage = requestUsage(node.usage)
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(
entry.kind === 'compaction'
? entry.request.usage
: entry.request?.usage ?? entry.node?.usage,
)
cumulativeUsage = addUsage(cumulativeUsage, usage)
requestBySeq.set(node.seq, {
turn: node.turn,
step: node.step,
if (entry.kind === 'ordinary') {
const request = entry.request
const node = entry.node
const turn = request?.turn ?? node?.turn
const step = request?.step ?? node?.step
if (turn === undefined || step === undefined) continue
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
numbered.push({
seq: entry.seq,
turn,
step,
group: `Step ${step}`,
number: index + 1,
...(request?.status === undefined ? {} : { status: request.status }),
...(request?.startedAt === undefined ? {} : { startedAt: request.startedAt }),
...(request?.completedAt === undefined ? {} : { completedAt: request.completedAt }),
...(request?.error === undefined ? {} : { error: request.error }),
...(request?.resultSeq === undefined ? {} : { resultSeq: request.resultSeq }),
...(request?.retry === undefined ? {} : { retry: request.retry }),
...(request?.maxRetries === undefined ? {} : { maxRetries: request.maxRetries }),
...(request?.retryDelayMs === undefined
? {}
: { retryDelayMs: request.retryDelayMs }),
...(provider === undefined ? {} : { provider }),
...(model === undefined ? {} : { model }),
...(requestConfig === undefined ? {} : { requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
continue
}
const request = entry.request
numbered.push({
seq: request.startSeq,
turn: request.turn,
step: 0,
group: `Compaction ${request.startSeq}`,
number: index + 1,
...(node.provenance?.provider === undefined
purpose: 'compaction',
status: request.status,
startedAt: request.startedAt,
completedAt: request.completedAt,
...(request.error === undefined ? {} : { error: request.error }),
resultSeq: request.startSeq,
...(request.provenance?.provider === undefined
? {}
: { provider: node.provenance.provider }),
...(node.provenance?.model === undefined
: { provider: request.provenance.provider }),
...(request.provenance?.model === undefined
? {}
: { model: node.provenance.model }),
...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }),
: { model: request.provenance.model }),
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
const selected: TrajectoryRequestNumber[] = []
const selectedKeys = new Set<string>()
for (const node of selectedNodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
const request = requestBySeq.get(node.seq)
if (request === undefined) continue
selected.push(request)
selectedKeys.add(`${node.turn}\u0000${node.step}`)
}
if (viewingCurrent && partial !== null && partial.step > 0) {
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
if (!selectedKeys.has(key)) {
selected.push({
const recorded = numbered.some(request =>
`${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn,
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentContext.prompt?.config?.provider === undefined
...(currentBranch.latest.prompt?.config?.provider === undefined
? {}
: { provider: currentContext.prompt.config.provider }),
...(currentContext.prompt?.config?.model === undefined
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config?.model === undefined
? {}
: { model: currentContext.prompt.config.model }),
...(currentContext.prompt?.config === undefined
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentContext.prompt.config }),
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return selected
}, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent])
const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
return numbered
}, [
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
requestAttempts,
])
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
return globalRequestNumbers.filter(request =>
request.seq === undefined
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
)
}, [currentBranch, globalRequestNumbers])
const visibleRequestAttempts = useMemo(
() => requestAttempts.filter(request =>
requestNumbers.some(number =>
number.purpose !== 'compaction' && number.seq === request.startSeq,
)),
[requestAttempts, requestNumbers],
)
const visibleCompactionRequests = useMemo(
() => compactionRequests.filter(request =>
requestNumbers.some(number =>
number.purpose === 'compaction' && number.seq === request.startSeq,
)),
[compactionRequests, requestNumbers],
)
const visiblePromptChanges = useMemo(
() => promptChanges.filter(change =>
trajectoryBranchContainsSeq(currentBranch, change.seq)),
[currentBranch, promptChanges],
)
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial: viewingCurrent ? partial : null,
runningCalls: viewingCurrent ? runningCalls : [],
partial,
runningCalls,
compactionRequests: visibleCompactionRequests,
requestAttempts: visibleRequestAttempts,
promptChanges: visiblePromptChanges,
callSchemas,
codeDispatches,
}),
[
selectedNodes, viewingCurrent, partial, runningCalls, callSchemas, codeDispatches,
selectedNodes, partial, runningCalls, visibleCompactionRequests,
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
],
)
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn => turn.groups.reduce((count, group) => count + group.cells.length, 0) > 1)
.filter(turn =>
turn.groups.reduce(
(count, group) =>
count + group.cells.filter(cell =>
cell.requestOnly !== true && cell.kind !== 'system').length,
0,
) > 1)
.map(turn => turn.turn),
[turns],
)
@@ -198,68 +309,50 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => {
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn)
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAllTurns = () => {
setCollapsedTurnsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (allTurnsCollapsed) {
for (const turn of collapsibleTurnIds) collapsed.delete(turn)
} else {
for (const turn of collapsibleTurnIds) collapsed.add(turn)
}
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const toggleAllAssistants = () => {
setCollapsedAssistantsByContext((current) => {
const next = new Map(current)
const collapsed = new Set(current.get(selectedContext.id) ?? EMPTY_IDS)
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (allAssistantsCollapsed) {
for (const index of collapsibleAssistantIds) collapsed.delete(index)
} else {
for (const index of collapsibleAssistantIds) collapsed.add(index)
}
next.set(selectedContext.id, collapsed)
return next
return collapsed
})
}
const selectContext = (id: number) => {
setSelectedContextId(id === currentContext.id ? null : id)
}
return (
<div className={css.root}>
<TrajectoryToolbar
{...contexts.length > 1
? {
contextLabel: contextLabel(selectedContext),
contextCurrent: viewingCurrent,
}
: {}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
@@ -267,27 +360,16 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
/>
<div className={css.contextLayout}>
{contexts.length > 1 && (
<ContextsPanel
contexts={contexts}
selectedId={selectedContext.id}
currentId={currentContext.id}
onSelect={selectContext}
/>
)}
<div className={css.ledger}>
<TrajectoryTable
key={selectedContext.id}
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
requestNumbers={requestNumbers}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
requestNumbers={requestNumbers}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
</div>
)
@@ -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)
}
@@ -6,7 +6,10 @@ import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
CompactionRequestView,
ConversationPromptChange,
ConversationSnapshot,
ModelRequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
@@ -32,6 +35,9 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
compactionRequests?: readonly CompactionRequestView[]
requestAttempts?: readonly ModelRequestView[]
promptChanges?: readonly ConversationPromptChange[]
callSchemas?: ConversationSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
@@ -62,13 +68,46 @@ interface TurnBucket {
groups: LaidGroup[]
}
type OrderedLayoutEntry =
| {
kind: 'node'
seq: number
node: ConversationSnapshot['nodes'][number]
nodeIndex: number
}
| {
kind: 'compaction'
seq: number
request: CompactionRequestView
}
| {
kind: 'system'
seq: number
change: ConversationPromptChange
}
| {
kind: 'request'
seq: number
request: ModelRequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
return entry.kind === 'system' && entry.change.kind === 'initial'
? Number.NEGATIVE_INFINITY
: entry.seq
}
/**
* Fold a snapshot into turn → Message/Step groups with expanded cells.
* @param input - nodes plus in-flight partial/runningCalls.
* @returns turns ordered by first appearance.
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const { nodes, partial, runningCalls, callSchemas, codeDispatches } = input
const {
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
promptChanges = [],
callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
@@ -114,10 +153,136 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
groups.push({ title, laid: [...laid] })
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
if (node === undefined) continue
const representedRequests = new Set<string>()
for (const node of nodes) {
if (node.kind === 'assistant' && node.step > 0) {
representedRequests.add(`${node.turn}\u0000${node.step}`)
}
}
if (partial !== null && partial.step > 0) {
representedRequests.add(`${partial.turn}\u0000${partial.step}`)
}
for (const call of runningCalls) {
if (call.step > 0) representedRequests.add(`${call.turn}\u0000${call.step}`)
}
const entries: OrderedLayoutEntry[] = [
...nodes.map((node, nodeIndex) => ({
kind: 'node' as const,
seq: node.seq,
node,
nodeIndex,
})),
...compactionRequests.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...promptChanges.map(change => ({
kind: 'system' as const,
seq: change.seq,
change,
})),
...requestAttempts
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
.map(request => ({
kind: 'request' as const,
seq: request.startSeq,
request,
})),
].sort((left, right) => layoutEntryOrder(left) - layoutEntryOrder(right))
for (const entry of entries) {
if (entry.kind === 'request') {
const { request } = entry
pushStep(request.turn, request.step, [{
absTime: finiteTime(request.startedAt),
cell: {
index: ++index,
kind: 'message',
text: '',
sourceSeq: request.startSeq,
requestOnly: true,
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
...(request.status === 'error' ? { isError: true } : {}),
},
}])
prevAbsTime = finiteTime(request.completedAt)
?? finiteTime(request.startedAt)
?? prevAbsTime
continue
}
if (entry.kind === 'system') {
const { change } = entry
const turn = enclosingPromptTurn(nodes, change.seq, partial)
pushMessage(turn, {
absTime: finiteTime(change.time),
cell: {
index: ++index,
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
promptDetail: change.prompt,
...(change.previous === undefined
? {}
: { previousPromptDetail: change.previous }),
timeSeconds: 0,
startedAt: finiteTime(change.time),
},
})
prevAbsTime = finiteTime(change.time) ?? prevAbsTime
continue
}
if (entry.kind === 'compaction') {
const request = entry.request
const rawOutput = request.rawOutput ?? request.summary
const thinkingDetail = rawOutput === undefined
? ''
: detailReasoning(rawOutput)
const cell: TrajectoryCellProps = {
index: ++index,
kind: 'compacted',
text: request.status === 'running'
? 'Compacting context…'
: request.status === 'error'
? request.error ?? 'Compaction failed'
: request.summary === undefined
? 'Context compacted'
: summarizeContent(request.summary),
sourceSeq: request.startSeq,
...(request.summary === undefined
? {}
: {
outputDetail: detailContent(request.summary),
outputBlocks: request.summary.map(block => sourceBlock(block)),
}),
...(thinkingDetail === '' ? {} : { thinkingDetail }),
...(rawOutput === undefined
? {}
: { sourceBlocks: rawOutput.map(block => sourceBlock(block)) }),
...(request.status === 'error' ? { isError: true } : {}),
timeSeconds: request.completedAt === null
? null
: durationSeconds(request.completedAt, request.startedAt),
startedAt: finiteTime(request.startedAt),
}
attachUsage(cell, request.usage as UsageLike | undefined)
bucket(request.turn).groups.push({
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
}],
})
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
continue
}
const { node, nodeIndex: i } = entry
if (node.kind === 'user' || node.kind === 'steering') {
// user/message has no turn on the wire; enclose it in the next assistant
// (or partial) turn, else open the turn after the last assistant.
@@ -128,6 +293,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
opensTurn: node.kind === 'user',
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
@@ -159,6 +327,9 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'context',
text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
...(node.meta === undefined ? {} : { messageMeta: node.meta }),
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
@@ -178,6 +349,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: {
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
@@ -241,7 +413,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
laidList.push(laid)
index = laid.cell.index
}
pushStep(call.turn, call.step > 0 ? call.step : 1, laidList)
if (call.step > 0) pushStep(call.turn, call.step, laidList)
else for (const laid of laidList) pushMessage(call.turn, laid)
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
@@ -365,6 +538,7 @@ function expandAssistant(
const message: TrajectoryCellProps = {
index: ++index,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
? summarizeText(messageText)
: thinkingText !== ''
@@ -432,6 +606,13 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
return ''
}
function promptChangeLabel(change: ConversationPromptChange): string {
if (change.kind === 'initial') return 'Initial System Prompt'
if (change.kind === 'system') return 'System Prompt Updated'
if (change.kind === 'tools') return 'Tools Updated'
return 'System Prompt and Tools Updated'
}
function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock {
switch (block.kind) {
case 'text': return { type: 'text', content: block.text }
@@ -524,6 +705,17 @@ function enclosingUserTurn(
return 1
}
function enclosingPromptTurn(
nodes: ConversationSnapshot['nodes'],
seq: number,
partial: ConversationSnapshot['partial'],
): number {
const next = nodes.find(node =>
node.seq > seq && node.kind === 'assistant' && node.step > 0)
if (next?.kind === 'assistant') return next.turn
return partial?.turn ?? 1
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return
@@ -641,7 +833,7 @@ function summarizeResult(node: ToolResultNode): string {
return summarizeText(block.text)
}
}
return node.call?.name ?? node.callId
return 'No output'
}
function detailResult(node: ToolResultNode): string {
@@ -655,6 +847,11 @@ function detailResult(node: ToolResultNode): string {
.map(block => block.type === 'text' ? block.text : '')
.join('\n')
if (text !== '') return text
if (
node.content.length === 0
|| node.content.every(block =>
block.type === 'text' && (typeof block.text !== 'string' || block.text === ''))
) return 'No output'
return JSON.stringify(node.content, null, 2)
}
@@ -665,6 +862,13 @@ function detailContent(content: readonly { type: string; text?: string }[]): str
.join('\n')
}
function detailReasoning(content: readonly { type: string; text?: string }[]): string {
return content
.filter(block => block.type === 'reasoning' && typeof block.text === 'string')
.map(block => block.text ?? '')
.join('\n')
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
@@ -1,9 +1,17 @@
/** Shared trajectory record data and formatting contracts. */
import type { HTMLAttributes } from 'react'
import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** Closed set of trajectory record kinds. */
export type TrajectoryCellKind = 'user' | 'context' | 'message' | 'tool' | 'subtool'
export type TrajectoryCellKind =
| 'system'
| 'user'
| 'context'
| 'compacted'
| 'message'
| 'tool'
| 'subtool'
/** Recorded inputs needed to derive assistant TTFT and decode throughput. */
export interface AssistantMetricDetail {
@@ -34,8 +42,20 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
text: string
/** Whether this user record opens a new model turn. */
opensTurn?: boolean
/** Source session-event seq for cross-record navigation. */
sourceSeq?: number
/** Producer provenance from a user-role message or context injection. */
messageSource?: unknown
/** Producer-owned model-hidden metadata carried beside the message source. */
messageMeta?: unknown
/** A separator-only anchor for an auxiliary request with no visible record. */
requestOnly?: boolean
/** Full request/message content for the details panel. */
inputDetail?: string
/** Complete system-prompt/tool-catalog state introduced by a SYSTEM record. */
promptDetail?: ConversationPromptSnapshot
/** System-prompt/tool-catalog state replaced by a SYSTEM update. */
previousPromptDetail?: ConversationPromptSnapshot
/** Full assistant/tool result content for the details panel. */
outputDetail?: string
/** Full assistant reasoning content for the details panel. */
@@ -1,6 +1,6 @@
/* Full-bleed, fixed-height host for the trajectory ledger and waterfall. */
.root {
--dsh-trajectory-toolbar-height: 48px;
--dsh-trajectory-toolbar-height: 40px;
display: flex;
flex-direction: column;
@@ -23,14 +23,6 @@
font: var(--dsw-font-xs-13);
}
.contextLayout {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.ledger {
display: flex;
flex: 1;
+6 -1
View File
@@ -125,7 +125,9 @@ export async function compactSurfaceRegion(
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
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)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
@@ -144,12 +146,14 @@ export async function compactSurfaceRegion(
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', {
content: framedSummary,
@@ -164,6 +168,7 @@ export async function compactSurfaceRegion(
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
@@ -6,7 +6,9 @@
import type { Context } from 'cordis'
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'
interface SummaryConfig {
@@ -85,9 +87,13 @@ export interface SummarizationInput {
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
summary: ContentBlock[]
/** Complete provider output before the text-only summary projection. */
rawOutput?: ContentBlock[]
provider: string
model: string
maxTokens?: number
/** Provider-reported usage for this summarization request. */
usage?: TokenUsage
}
/**
@@ -145,15 +151,18 @@ export async function summarizeWithLlm(
const error = finishError(assembler.finish)
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)) {
throw new Error('summarization produced no text summary content')
}
return {
summary,
rawOutput,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,
...(assembler.usage === undefined ? {} : { usage: assembler.usage }),
}
}
+7 -1
View File
@@ -7,7 +7,7 @@
* @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' {
interface SessionEventMap {
@@ -21,6 +21,8 @@ declare module '@deepseek-ai/dsh-session' {
*/
'compact/summary': {
summary: ContentBlock[]
/** Complete provider output before the backend's safe summary projection. */
rawOutput?: ContentBlock[]
shadowedRange: { start: number; end: number }
shadowedSeqs: number[]
shadowedTokenCount: number
@@ -35,6 +37,8 @@ declare module '@deepseek-ai/dsh-session' {
model: string
/** The generation cap the summarize call sent, when one applied. */
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. */
'compact/end': { turn: number; error?: string }
@@ -51,6 +55,8 @@ export interface CompactionResult {
endSeq: number
/** The summary content blocks produced by the backend. */
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
* (`start`) and last (`end`) surface nodes of the replaced range. A
+4
View File
@@ -1293,6 +1293,10 @@ importers:
version: 18.3.1
packages/client/ui-trajectory:
dependencies:
diff:
specifier: ^9.0.0
version: 9.0.0
devDependencies:
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
+47
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 })
}
})
})