Merge remote-tracking branch 'origin/master' into worktree-renameweb

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/test-runtime/src/sessions.ts
This commit is contained in:
imccyu
2026-07-29 21:45:29 +08:00
115 changed files with 10872 additions and 940 deletions

View File

@@ -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 packages/client/runtime/README.md
README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220
README.zh.md: 5531cd4724df2c63a9a5c6d1923b52c1f578db10
README.md: b51cc0276d8635ea9faa506e30246a107c1c1418
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾`projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表

View File

@@ -0,0 +1,35 @@
import type {
RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionHistoryInspection } from '../sessions/history.ts'
import type { ObservableSnapshot } from './store.ts'
/** Observable state of one independently loaded session history ledger. */
export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
inspection: SessionHistoryInspection
}
/** Read-only history source addressed by session id. */
export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the tail and exhaust every available older page.
* @param signal - Consumer lifetime; abort is observed between page requests.
* @returns When the available ledger is complete or stops advancing.
*/
loadAll(signal?: AbortSignal): Promise<void>
}
/** Runtime service resolving independent history sources. */
export interface ISessionHistory {
/**
* Resolve the identity-stable source for a session.
* @param sessionId - Host session identity.
* @returns The source owned outside Session and SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace
}

View File

@@ -5,6 +5,7 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
@@ -12,6 +13,7 @@ import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { SessionHistoryService } from './session-history/service.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
@@ -21,6 +23,9 @@ export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type {
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
} from './contract/session-history.ts'
export type { ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
@@ -38,10 +43,19 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
export type { SessionHistoryInspection } from './sessions/history.ts'
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
@@ -120,6 +134,8 @@ declare module 'cordis' {
slots: import('./slots.ts').SlotsService
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** Read-only history sources isolated from Chat sessions and workspace state. */
sessionHistory: import('./contract/session-history.ts').ISessionHistory
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
@@ -135,30 +151,55 @@ export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const sessionHistory = new SessionHistoryService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
'runtime: initial Workspace selection',
)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onMuxEnvelope: (envelope) => {
sessions.handleMuxEnvelope(envelope)
try {
sessionHistory.handleMuxEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history frame routing failed:', error)
}
},
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches) subscribe on ctx.
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
try {
sessionHistory.handleHostEnvelope(envelope)
} catch (error) {
console.error('[web-runtime] history host-frame routing failed:', error)
}
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
try {
sessionHistory.handleConnected()
} catch (error) {
console.error('[web-runtime] history reconnect failed:', error)
}
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') sessions.handleDisconnected()
if (state === 'reconnecting') {
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
}
}
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')

View File

@@ -0,0 +1,472 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type {
HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
interface CallIndexEntry {
name: string
argsRaw: string
time: number
callView: ToolCallView | null
}
interface FoldedContext {
generation: number
nodes: readonly number[]
originSeq?: number
}
interface AssistantStepMetadata {
stepStartTime: number | null
firstTokenTime: number | null
}
/** Immutable conversation projections derived only from the history source. */
export interface ConversationHistoryProjection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
// Trajectory owns surface-window reconstruction so its immutable ledger does
// not depend on Chat's live fold adapter or Session's mutable state.
/* jscpd:ignore-start */
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/* jscpd:ignore-end */
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
if (event?.type !== 'user/message') return 'rewrite'
const source = event.data.source
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
if (source.plugin === 'compact') return 'compaction'
if (source.plugin === 'rewind') return 'rewind'
}
return 'rewrite'
}
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
switch (chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return chunk.text !== ''
case 'tool-call-delta':
return chunk.argumentsDelta !== '' || chunk.name !== undefined
default:
return false
}
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
for (const event of events) {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: [...surface.nodes],
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
replay.push(event)
}
contexts.push({
generation,
nodes: [...surface.nodes],
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
}
// History projection owns its node mapping so Chat's live adapter remains free
// of inspection metadata and lifecycle coupling.
/* jscpd:ignore-start */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
): ConversationNode {
switch (event.type) {
case 'user/message':
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
case 'assistant/message':
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
provenance: {
provider: event.data.message.source.provider,
model: event.data.message.source.model,
},
...(requestConfig === undefined ? {} : { requestConfig }),
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId,
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
callTime: call?.time ?? null,
content: result.content, isError: result.isError === true,
...(event.data.error === undefined ? {} : { error: event.data.error }),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
default:
return {
kind: 'unknown', seq: event.seq, time: event.time,
type: event.type, data: (event as { data?: unknown }).data,
}
}
}
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
> {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
for (const entry of entries) {
const { event } = entry
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
if ((event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
// History independently reproduces the public settled-call shape instead
// of consuming Session's live code-dispatch projection.
/* jscpd:ignore-start */
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
}
codeDispatches.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
/* jscpd:ignore-end */
continue
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (partial === null || partial.turn !== turn || partial.step !== step) {
partial = new PartialAccumulator(turn, step)
}
partial.push(chunk)
break
}
case 'assistant/message':
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
break
case 'tool/call':
// History reconstructs its own in-flight index; this intentionally
// mirrors the published Chat node shape, not Chat's mutable state.
/* jscpd:ignore-start */
openCalls.set(String(event.data.callId), {
callId: String(event.data.callId),
name: event.data.name,
argsRaw: event.data.arguments,
turn: event.data.turn,
step: event.data.step,
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
})
/* jscpd:ignore-end */
break
case 'tool/result':
openCalls.delete(String(event.data.message.source.callId))
break
case 'turn/end': {
if (partial !== null && partial.turn === event.data.turn) {
const { blocks } = partial.toPartial()
const visible = blocks.some(block =>
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
if (visible) {
interruptedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: partial.turn, step: partial.step, blocks, interrupted: true,
})
}
partial = null
}
let callOffset = 0
for (const [callId, call] of openCalls) {
if (call.turn !== event.data.turn) continue
openCalls.delete(callId)
// Interrupted terminal nodes are reconstructed independently so a
// Trajectory replay cannot observe Session's frozen-node lifecycle.
/* jscpd:ignore-start */
interruptedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView,
resultView: null,
})
/* jscpd:ignore-end */
}
break
}
default:
break
}
}
return {
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
codeDispatches,
}
}
/**
* Project one immutable history ledger without reading or mutating Chat state.
* @param entries - Contiguous history entries in sequence order.
* @returns Event order, context lineage, and transient tail state.
*/
export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const baseSeq = events[0]?.seq ?? 0
const padded = [
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
...events,
]
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
const assistantTimings = new Map<number, AssistantTiming>()
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
let activeRequestConfig: AssistantRequestConfig | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let contextGeneration = 0
for (const [index, event] of events.entries()) {
const view = entries[index]?.view
if (event.type === 'tool/call') {
callIndex.set(String(event.data.callId), {
name: event.data.name,
argsRaw: event.data.arguments,
time: event.time,
callView: view?.for === 'call' ? view.view : null,
})
} else if (event.type === 'tool/result' && view?.for === 'result') {
resultViews.set(event.seq, view.view)
}
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
contextGeneration++
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
}
if (event.type === 'request/header') {
activeRequestConfig = event.data.header.config
activePrompt = {
config: event.data.header.config,
system: event.data.header.system ?? '',
tools: event.data.header.tools ?? [],
}
promptsByContext.set(contextGeneration, activePrompt)
} else if (event.type === 'step/start') {
assistantSteps.set(
assistantStepKey(event.data.turn, event.data.step),
{ stepStartTime: event.time, firstTokenTime: null },
)
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
const key = assistantStepKey(event.data.turn, event.data.step)
const current = assistantSteps.get(key) ?? {
stepStartTime: null,
firstTokenTime: null,
}
if (current.firstTokenTime === null) {
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
}
} else if (event.type === 'assistant/message') {
assistantTimings.set(
event.seq,
{
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
stepStartTime: null,
firstTokenTime: null,
}),
completedTime: event.time,
},
)
if (activeRequestConfig !== undefined) {
assistantRequestConfigs.set(event.seq, activeRequestConfig)
}
}
}
const nodeCache = new Map<number, ConversationNode>()
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = padded[seq]
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
callIndex,
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
)
nodeCache.set(seq, node)
return node
}
const eventNodes = events.flatMap((event) => {
const node = materialize(event.seq)
return node === undefined ? [] : [node]
})
let contexts: readonly ConversationContext[]
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
} else {
try {
contexts = foldContexts(padded).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
})
const prompt = promptsByContext.get(context.generation)
if (context.originSeq === undefined) {
return {
id: context.generation,
...(prompt === undefined ? {} : { prompt }),
nodes,
}
}
const originEvent = padded[context.originSeq]
return {
id: context.generation,
parentId: context.generation - 1,
origin: contextOriginKind(originEvent),
originSeq: context.originSeq,
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
...(prompt === undefined ? {} : { prompt }),
nodes,
}
})
} catch (error) {
console.error('[web-runtime] history surface fold failed, using event order:', error)
contexts = [{
id: 0,
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
nodes: eventNodes,
}]
}
}
return {
eventNodes,
contexts,
...projectTransient(entries),
}
}

View File

@@ -0,0 +1,66 @@
import type { Context } from 'cordis'
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
ISessionHistory, SessionHistoryFace,
} from '../contract/session-history.ts'
import { SessionHistorySource } from './source.ts'
/** Root registry and frame router for independent inspection histories. */
export class SessionHistoryService implements ISessionHistory {
private readonly sources = new Map<SessionId, SessionHistorySource>()
/**
* @param ctx - Client root context.
* @param api - Shared wire client.
*/
constructor(ctx: Context, private readonly api: IApiClient) {
ctx.reflect.provide('sessionHistory', this, undefined)
}
/**
* Resolve one identity-stable history source.
* @param sessionId - Host session identity.
* @returns Source independent from SessionManager.
*/
source(sessionId: SessionId): SessionHistoryFace {
let source = this.sources.get(sessionId)
if (source === undefined) {
source = new SessionHistorySource(sessionId, this.api)
this.sources.set(sessionId, source)
}
return source
}
/**
* Route history-relevant mux frames only to an existing source.
* @param envelope - Validated mux envelope.
*/
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
}
/**
* Drop a removed session's independent history source.
* @param envelope - Validated host envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
const frame = envelope.payload
if (frame.type !== 'host/session-removed') return
this.sources.get(frame.sessionId)?.dispose()
this.sources.delete(frame.sessionId)
}
/** Invalidate requests from the dead connection generation. */
handleDisconnected(): void {
for (const source of this.sources.values()) source.handleDisconnected()
}
/** Rebuild every previously activated source from the new generation. */
handleConnected(): void {
for (const source of this.sources.values()) source.resync()
}
}

View File

@@ -0,0 +1,352 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
const HISTORY_PAGE_MESSAGES = 50
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
}
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
private error: RpcError | null = null
private generation = 0
private persistentConsumer = false
private readonly consumerSignals = new Set<AbortSignal>()
private openPromise: Promise<void> | null = null
private olderPromise: Promise<void> | null = null
private stitching = false
private liveBuffer: HistoryEntry[] = []
private subscribedLastSeq: number | null = null
private inspectionCache: {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param sessionId - Host session identity.
* @param api - Shared wire client.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Subscribe to ledger changes.
* @param listener - Change callback.
* @returns Unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached ledger snapshot.
* @returns Stable snapshot until the source changes.
*/
getSnapshot(): SessionHistorySnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
/**
* Load the tail and exhaust all available older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
this.trackConsumer(signal)
await this.open()
while (
!isAborted(signal)
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
}
}
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
await this.open()
while (
this.hasConsumer()
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
}
}
/**
* Route a relevant mux frame without involving the Chat session.
* @param frame - Session-addressed frame.
*/
handleMuxFrame(frame: MuxFrame): void {
if (frame.type === 'session/subscribed') {
this.subscribedLastSeq = frame.lastSeq
return
}
if (frame.type !== 'session/event') return
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
}
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
handleDisconnected(): void {
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
}
}
/** Rebuild an activated ledger from the new connection generation. */
resync(): void {
if (!this.hasConsumer()) return
this.generation++
this.openPromise = null
this.olderPromise = null
this.stitching = false
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
void this.loadForConsumers()
}
/** Stop future refresh work after the host removes the session. */
dispose(): void {
this.persistentConsumer = false
this.consumerSignals.clear()
this.generation++
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
}
private open(): Promise<void> {
if (this.state === 'ready') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const generation = this.generation
const operation = this.doOpen(generation)
const settled = operation.finally(() => {
if (this.openPromise === settled) this.openPromise = null
})
this.openPromise = settled
return settled
}
private trackConsumer(signal: AbortSignal | undefined): void {
if (signal === undefined) {
this.persistentConsumer = true
return
}
if (this.consumerSignals.has(signal)) return
this.consumerSignals.add(signal)
signal.addEventListener('abort', () => {
this.consumerSignals.delete(signal)
}, { once: true })
}
private hasConsumer(): boolean {
return this.persistentConsumer || this.consumerSignals.size > 0
}
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation) return
if (!result.ok) {
this.state = 'error'
this.error = result.error
return
}
this.installTail(result.value.events, result.value.hasMore, true)
const tailSeq = this.tailSeq()
if (
this.subscribedLastSeq !== null
&& tailSeq !== null
&& this.subscribedLastSeq > tailSeq
) {
result = (await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})).result
if (generation !== this.generation) return
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
}
this.state = 'ready'
} catch (error) {
if (generation !== this.generation) return
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
}
}
private loadOlder(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
const operation = (async () => {
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
beforeSeq: this.baseSeq,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
const older = result.value.events
if (older.length === 0) {
this.hasMore = result.value.hasMore
return
}
const tail = older.at(-1)
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
console.error(
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
)
this.hasMore = false
return
}
this.entries = [...older, ...this.entries]
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
console.error('[web-runtime] inspection history paging failed:', error)
}
})()
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
})
this.olderPromise = settled
return settled
}
private installTail(
tail: readonly HistoryEntry[],
hasMore: boolean,
replace: boolean,
): void {
if (replace) {
this.entries = [...tail]
this.hasMore = hasMore
} else {
const firstSeq = tail[0]?.event.seq
const prefix = firstSeq === undefined
? this.entries
: this.entries.filter(entry => entry.event.seq < firstSeq)
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
}
private acceptLive(entry: HistoryEntry): void {
if (this.state === 'loading' || this.stitching) {
this.liveBuffer.push(entry)
return
}
if (this.state !== 'ready') return
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
this.liveBuffer.push(entry)
void this.repairGap()
return
}
this.appendLive(entry)
this.notifier.markDirty()
}
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries = [...this.entries, entry]
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
const generation = this.generation
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId,
maxMessages: HISTORY_PAGE_MESSAGES,
})
if (result.ok && generation === this.generation && this.state === 'ready') {
this.installTail(result.value.events, result.value.hasMore, false)
}
} catch (error) {
console.error('[web-runtime] inspection history gap repair failed:', error)
} finally {
if (generation === this.generation) this.stitching = false
}
}
private tailSeq(): number | null {
return this.entries.at(-1)?.event.seq ?? null
}
private buildSnapshot(): SessionHistorySnapshot {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
}
}

View File

@@ -0,0 +1,23 @@
import type { ConversationNode } from './conversation.ts'
import type { ConversationPromptSnapshot } from './request-inspection.ts'
/** Operation that started a new append-only model context. */
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
/** One immutable model-context generation reconstructed from surface replacements. */
export interface ConversationContext {
/** Zero-based generation within the session; stable across later appends. */
id: number
/** Previous generation in this session; absent for the initial context. */
parentId?: number
/** Why this generation exists; absent for the initial context. */
origin?: ConversationContextOriginKind
/** Event seq of the replacement that created this generation. */
originSeq?: number
/** Unix epoch ms of the replacement that created this generation. */
createdAt?: number
/** Latest request header observed in this generation, inherited until a later header replaces it. */
prompt?: ConversationPromptSnapshot
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
nodes: readonly ConversationNode[]
}

View File

@@ -10,9 +10,26 @@ import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
export interface AssistantRequestConfig {
provider: string
model: string
purpose?: string
thinking?: string
reasoningEffort?: string
temperature?: number
maxTokens?: number
stop?: readonly string[]
}
/** Stable provider/model identity reported for one completed request. */
export interface AssistantProvenanceView {
provider: string
model: string
}
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -54,6 +71,16 @@ export interface UserMessageNode {
source: unknown
}
/** Recorded boundaries used to derive assistant latency and throughput. */
export interface AssistantTiming {
/** Matching step/start timestamp, or null when it is outside the current event window. */
stepStartTime: number | null
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
firstTokenTime: number | null
/** Final assistant/message timestamp. */
completedTime: number
}
/** A finalized (or interruption-frozen) assistant message. */
export interface AssistantMessageNode {
kind: 'assistant'
@@ -64,6 +91,10 @@ export interface AssistantMessageNode {
step: number
blocks: readonly AssistantBlock[]
usage?: unknown
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
/** Timing derived from the recorded step/chunk/message event sequence. */
timing?: AssistantTiming
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
interrupted?: true

View File

@@ -7,7 +7,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts'
@@ -33,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
@@ -137,7 +144,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
@@ -160,6 +167,7 @@ export class FoldAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}

View File

@@ -0,0 +1,66 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
contexts: readonly ConversationContext[]
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
* the entries and replays event order and request lifecycle state.
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
export function createHistoryInspection(
loadEntries: () => readonly HistoryEntry[],
): SessionHistoryInspection {
let entries: readonly HistoryEntry[] | undefined
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const historyEntries = () => entries ??= loadEntries()
const conversationProjection = () =>
conversation ??= projectConversationHistory(historyEntries())
const requestProjection = () =>
requests ??= inspectRequests(historyEntries())
return {
get eventNodes() {
return conversationProjection().eventNodes
},
get contexts() {
return conversationProjection().contexts
},
get interruptedNodes() {
return conversationProjection().interruptedNodes
},
get partial() {
return conversationProjection().partial
},
get runningCalls() {
return conversationProjection().runningCalls
},
get codeDispatches() {
return conversationProjection().codeDispatches
},
get requests() {
return requestProjection().requests
},
get callSchemas() {
return requestProjection().callSchemas
},
}
}

View File

@@ -0,0 +1,401 @@
// Request-centric inspection read model. Ordinary generation and compaction
// calls share one chronological projection; presentation-specific grouping
// remains in the trajectory consumer.
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
/** Complete model-visible request header in force for an ordinary generation. */
export interface ConversationPromptSnapshot {
/** Provider/model and sampling configuration from the effective request header. */
config: AssistantRequestConfig
/** Rendered system prompt text; empty when the request had no system prompt. */
system: string
/** Complete tool catalog sent with the request, including tools that were never called. */
tools: readonly ToolSchema[]
}
/** System/tool change introduced while preparing one ordinary request. */
export interface RequestPromptChange {
/** 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 prompt differs from the previous recorded state. */
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
/** State immediately before this change; absent for the initial header. */
previous?: ConversationPromptSnapshot
}
/** One provider request reconstructed from durable request lifecycle events. */
export interface RequestView {
/** Request category; compaction is a purpose, not a separate projection. */
purpose: 'assistant' | 'compaction'
/** Sequence that opened the operation represented by this request. */
startSeq: number
turn: number
/** Agent-loop step, or zero for a direct compaction request. */
step: number
startedAt: number
completedAt: number | null
status: 'running' | 'complete' | 'error'
error?: string
/** Effective ordinary request input, inherited until a later header changes it. */
prompt?: ConversationPromptSnapshot
/** Prompt change logged while preparing this request. */
promptChange?: RequestPromptChange
provenance?: AssistantProvenanceView
requestConfig?: AssistantRequestConfig
usage?: unknown
/** Assistant message or compaction summary sequence produced by this request. */
resultSeq?: number
/** Compaction replacement message sequence, when one was committed. */
replacementSeq?: number
/** Safe compaction summary projection. */
summary?: readonly ContentBlock[]
/** Complete compaction provider output before the safe projection. */
rawOutput?: readonly ContentBlock[]
/** Retry ordinal scheduled after a failed ordinary request. */
retry?: number
maxRetries?: number
retryDelayMs?: number
}
/** Immutable request-centric projection derived from one history window. */
export interface RequestInspectionSnapshot {
requests: readonly RequestView[]
callSchemas: ReadonlyMap<string, ToolSchema>
}
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
export function inspectRequests(
entries: readonly HistoryEntry[],
): RequestInspectionSnapshot {
const events = entries.map(entry => entry.event)
return {
requests: deriveRequests(events),
callSchemas: deriveCallSchemas(events),
}
}
interface RetryEvent {
type: 'llm/retry'
seq: number
time: number
data: {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: { message: string }
}
}
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 }
}
function requestKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
const previous = current as TokenUsage | undefined
return {
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
? {}
: {
cacheReadTokens:
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
}),
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
? {}
: {
cacheWriteTokens:
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
}),
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: {
reasoningTokens:
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
}),
}
}
function deriveCallSchemas(
events: readonly SessionEvent[],
): ReadonlyMap<string, ToolSchema> {
let active = new Map<string, ToolSchema>()
const calls = new Map<string, ToolSchema>()
const capture = (callId: string, name: string): void => {
if (calls.has(callId)) return
const schema = active.get(name)
if (schema !== undefined) calls.set(callId, schema)
}
for (const event of events) {
if (event.type === 'request/header') {
const tools: unknown = event.data.header.tools
active = new Map(
Array.isArray(tools)
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
: [],
)
continue
}
if (event.type === 'tool/call') {
capture(String(event.data.callId), event.data.name)
continue
}
const type = event.type as string
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
const data = event.data as unknown as { subCallId: string; name: string }
capture(data.subCallId, data.name)
}
}
return calls
}
function promptChange(
previous: ConversationPromptSnapshot | undefined,
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
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) return
return {
seq: event.seq,
time: event.time,
kind: previous === undefined
? 'initial'
: systemChanged && toolsChanged
? 'system-and-tools'
: systemChanged
? 'system'
: 'tools',
...(previous === undefined ? {} : { previous }),
}
}
/** Project ordinary and compaction provider calls into one chronological request stream. */
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
const update = (index: number | undefined, change: Partial<RequestView>): void => {
if (index === undefined) return
const request = requests[index]
if (request !== undefined) requests[index] = { ...request, ...change }
}
for (const sourceEvent of events) {
if (sourceEvent.type === 'step/start') {
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
turn,
step,
startedAt: sourceEvent.time,
completedAt: null,
status: 'running',
...(activePrompt === undefined
? {}
: { prompt: activePrompt, requestConfig: activePrompt.config }),
})
activeStep = key
continue
}
if (sourceEvent.type === 'request/header') {
const tools: unknown = sourceEvent.data.header.tools
const prompt: ConversationPromptSnapshot = {
config: sourceEvent.data.header.config,
system: sourceEvent.data.header.system ?? '',
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
}
const change = promptChange(activePrompt, prompt, sourceEvent)
activePrompt = prompt
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
prompt,
requestConfig: prompt.config,
...(change === undefined ? {} : { promptChange: change }),
})
continue
}
if (
sourceEvent.type === 'assistant/chunk'
&& sourceEvent.data.chunk.type === 'usage'
) {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
})
continue
}
if (sourceEvent.type === 'assistant/message') {
const index = ordinaryByStep.get(
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
)
const request = index === undefined ? undefined : requests[index]
update(index, {
completedAt: sourceEvent.time,
status: 'complete',
resultSeq: sourceEvent.seq,
provenance: {
provider: sourceEvent.data.message.source.provider,
model: sourceEvent.data.message.source.model,
},
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
? {}
: { usage: sourceEvent.data.usage }),
})
continue
}
if (sourceEvent.type === 'step/end') {
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
const index = ordinaryByStep.get(key)
const request = index === undefined ? undefined : requests[index]
if (request?.status === 'running') {
update(index, {
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(ordinaryByStep.get(requestKey(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') {
const reason = sourceEvent.data.reason
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
})
continue
}
const type = sourceEvent.type as string
if (type === 'compact/start') {
const event = sourceEvent as unknown as CompactionStartEvent
activeCompaction = requests.length
requests.push({
purpose: 'compaction',
startSeq: event.seq,
turn: event.data.turn,
step: 0,
startedAt: event.time,
completedAt: null,
status: 'running',
})
continue
}
if (type === 'compact/summary' && activeCompaction !== undefined) {
const event = sourceEvent as unknown as CompactionSummaryEvent
update(activeCompaction, {
resultSeq: 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'
&& activeCompaction !== undefined
&& isCompactionSource(sourceEvent.data.source)
) {
update(activeCompaction, { replacementSeq: sourceEvent.seq })
continue
}
if (type !== 'compact/end' || activeCompaction === undefined) continue
const event = sourceEvent as unknown as CompactionEndEvent
update(activeCompaction, {
completedAt: event.time,
status: event.data.error === undefined ? 'complete' : 'error',
...(event.data.error === undefined ? {} : { error: event.data.error }),
})
activeCompaction = undefined
}
return requests.sort((left, right) => left.startSeq - right.startSeq)
}
function isCompactionSource(source: unknown): boolean {
return typeof source === 'object'
&& source !== null
&& 'kind' in source
&& source.kind === 'plugin'
&& 'plugin' in source
&& source.plugin === 'compact'
}

View File

@@ -8,6 +8,7 @@ import { createUserMessage, CallId, createMessage, createToolResultMessage } fro
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev, plainTurn } from './event-script.ts'
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
@@ -27,6 +28,7 @@ describe('FoldAdapter', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
@@ -35,6 +37,52 @@ describe('FoldAdapter', () => {
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
@@ -114,6 +162,68 @@ describe('FoldAdapter', () => {
}
})
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
@@ -130,6 +240,44 @@ describe('FoldAdapter', () => {
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)

View File

@@ -0,0 +1,184 @@
import { describe, expect, it } from 'vitest'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
const at = (seq: number, type: string, data: unknown): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent
const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] =>
events.map(event => ({ event }))
describe('inspectRequests', () => {
it('projects ordinary and compaction calls into one chronological request stream', () => {
const events = [
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'system',
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
at(3, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'done' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 5, outputTokens: 2 },
}),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'compact/start', { turn: 1 }),
at(6, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
rawOutput: [
{ type: 'reasoning', text: 'thought' },
{ type: 'text', text: 'summary' },
],
provider: 'fake',
model: 'compact-model',
usage: { inputTokens: 8, outputTokens: 3 },
}),
at(7, 'user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
})),
at(8, 'compact/end', { turn: 1 }),
]
const snapshot = inspectRequests(entriesOf(events))
expect(snapshot.requests).toMatchObject([
{
purpose: 'assistant',
startSeq: 0,
resultSeq: 3,
status: 'complete',
prompt: {
config: { provider: 'fake', model: 'model' },
system: 'system',
},
promptChange: { seq: 1, kind: 'initial' },
},
{
purpose: 'compaction',
startSeq: 5,
resultSeq: 6,
replacementSeq: 7,
status: 'complete',
summary: [{ type: 'text', text: 'summary' }],
},
])
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('captures schemas for nested tool dispatches from the active request header', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: [{
name: 'read',
description: 'Read a file.',
parameters: { type: 'object' },
}],
},
}),
at(1, 'tool/code-dispatch-start', {
parentCallId: 'parent',
subCallId: 'nested',
name: 'read',
arguments: {},
}),
]))
expect(snapshot.callSchemas.get('nested')?.name).toBe('read')
})
it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => {
const chunkUsage = { inputTokens: 21, outputTokens: 3 }
const retryUsage = {
inputTokens: 5,
outputTokens: 2,
cacheReadTokens: 8,
reasoningTokens: 1,
}
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: chunkUsage },
}),
at(2, 'llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 100,
failure: { message: 'rate limited' },
}),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: retryUsage },
}),
at(4, 'assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'recovered' }],
source: { provider: 'fake', model: 'model' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
}),
]))
expect(snapshot.requests[0]).toMatchObject({
status: 'complete',
usage: {
inputTokens: 26,
outputTokens: 5,
cacheReadTokens: 8,
reasoningTokens: 1,
},
})
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
tools: '{{tools}}',
},
}),
at(2, 'tool/call', {
turn: 1,
step: 1,
callId: 'call-1',
name: 'read',
arguments: '{}',
}),
]))
expect(snapshot.callSchemas).toEqual(new Map())
expect(snapshot.requests[0]?.prompt?.tools).toEqual([])
})
})

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return histResponse(pages[1]!, true)
return histResponse(pages[0]!, false)
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
it('pins a lazy inspection to the entries in its source snapshot', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const before = source.getSnapshot()
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.user(6, 'later'),
})
expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3])
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 6])
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(6, 1, '新问', '新答'), true)
: Promise.resolve(err({
code: 'internal',
message: 'page unavailable',
details: {},
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) {
return histResponse(plainTurn(12, 2, '最新问', '最新答'), true)
}
olderStarted.resolve(undefined)
return middle.promise
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[],
hasMore: true,
}))
await complete
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
})

View File

@@ -727,6 +727,7 @@ describe('resync', () => {
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
})
})
describe('run_code sub-dispatch indexing', () => {
@@ -840,20 +841,23 @@ describe('reference stability (the memo contract)', () => {
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
feed(ev.stepStart(7, 1))
feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
const before = session.getSnapshot()
// A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '与工具无关的流式'))
// A chunk storm touches partial/nodes only: unrelated projections keep identity.
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '与工具无关的流式'))
const after = session.getSnapshot()
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()
expect(resolved.runningCalls).not.toBe(after.runningCalls)
expect(resolved.pending).toBe(after.pending)
feed(ev.assistant(12, 1, '完成'))
expect(session.getSnapshot()).not.toBe(resolved)
})
})

View File

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

View File

@@ -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 packages/client/ui-conversation/README.md
README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f
README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f
README.md: 2f7ca50fe241ddc7927b9cddf9496d1b990d372d
README.zh.md: 8a27ccb1ab59e98ca893190887a81fc0c9f4b910

View File

@@ -20,7 +20,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏`'conversation.input.plan'`位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取 host 折叠值owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
输入栏声明两个会话作用域的单实例 seat`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取 host 折叠owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -17,7 +17,9 @@ import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
import type {
ComposerBarOwnerProps,
} from '../src/client/contract/slots.ts'
/** Machine-backed wiring over a sink spy. */
function fakeWiring() {
@@ -99,7 +101,14 @@ function mount(
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
views={{
list: () => [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
],
subscribe: () => () => {},
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
/>
@@ -207,6 +216,13 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('keeps pending takeover interaction accessible outside the Chat view', () => {
const b = mount(conversationSnapshot({ pending: [{} as never] }))
act(() => { b.chat.actions.setView('trajectory') })
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
it('rolls the pending workspace label back when switching fails', async () => {
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
const b = mount(

View File

@@ -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 packages/client/ui-primitives/README.md
README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
README.zh.md: 567881e8ca7d5e8017f82884cd638f08b13fc7e7
README.md: 3ff2717af7eeb6ef7f85c24456c7fe23b09d0faa
README.zh.md: 56c4e9f1dae3eee1dc7ea64616e2ed4d536928e2

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, and TerminalBlock. Contract: api-contracts v3 §8.
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -2,12 +2,11 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock以及 TerminalBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器,以及 TerminalBlock。契约api-contracts v3 §8。
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`因此重复空格、制表符与缩进续行都原样呈现同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循光标按终端列推进8 列制表位emoji 与 CJK 占两列组合标记不占列SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-primitives",
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)",
"description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -23,6 +23,9 @@
"@shikijs/langs": "^4.3.1",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",

View File

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

View File

@@ -0,0 +1,602 @@
import clsx from 'clsx'
import { useEffect, useId, useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
UIEvent as ReactUIEvent,
} from 'react'
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
import { Menu } from './Menu.tsx'
import type { MenuEntry } from './Menu.tsx'
import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
type JsonPath = readonly (number | string)[]
interface RowTarget {
path: JsonPath
value: unknown
}
interface CopyTarget extends RowTarget {
left: number
side: 'bottom' | 'top'
top: number
}
function isExpandableValue(value: unknown): value is object | unknown[] {
return typeof value === 'object' && value !== null && !(value instanceof Date)
}
function entriesOf(value: object | unknown[]): readonly (readonly [string, unknown])[] {
if (Array.isArray(value)) {
return value.map((item, index) => [String(index), item] as const)
}
return Object.keys(value).map(key => [
key,
(value as Record<string, unknown>)[key],
] as const)
}
function bracketOf(value: object | unknown[]): readonly [string, string] {
return Array.isArray(value) ? ['[', ']'] : ['{', '}']
}
function previewPrimitive(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
if (typeof value === 'bigint') {
return <span className={css.otherValue}>{value.toString()}</span>
}
if (typeof value === 'undefined') {
return <span className={css.otherValue}>undefined</span>
}
if (typeof value === 'symbol') {
return <span className={css.otherValue}>{value.description ?? 'Symbol'}</span>
}
if (typeof value === 'function') {
return <span className={css.otherValue}>{value.name || 'Function'}</span>
}
return null
}
function previewValue(value: unknown, depth: number): ReactNode {
if (!isExpandableValue(value)) return previewPrimitive(value)
const array = Array.isArray(value)
const entries = entriesOf(value)
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
const visible = entries.slice(0, limit)
const [open, close] = bracketOf(value)
return (
<>
<span className={css.punctuation}>{open}</span>
{depth >= PREVIEW_DEPTH_LIMIT
? <span className={css.previewEllipsis}></span>
: visible.map(([key, item], index) => (
<span key={key}>
{index > 0 && <span className={css.punctuation}>, </span>}
{!array && (
<>
<span className={css.previewProperty}>{key}</span>
<span className={css.punctuation}>: </span>
</>
)}
{previewValue(item, depth + 1)}
</span>
))}
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
<span className={css.previewEllipsis}>, </span>
)}
<span className={css.punctuation}>{close}</span>
</>
)
}
function primitiveValue(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'bigint') {
return <span className={css.numberValue}>{`${value.toString()}n`}</span>
}
if (value instanceof Date) {
return <span className={css.otherValue}>{value.toISOString()}</span>
}
if (typeof value === 'function') {
return <span className={css.otherValue}>function() {'{ }'}</span>
}
if (typeof value === 'undefined') {
return <span className={css.otherValue}>undefined</span>
}
return <span className={css.otherValue}>{(value as symbol).toString()}</span>
}
function fieldText(field: string): string {
return field === '' ? '""' : field
}
function pathId(path: JsonPath): string {
return path.map(part => (
typeof part === 'number' ? `n${String(part)}` : `s${String(part.length)}:${part}`
)).join('/')
}
function claimFocus(button: HTMLElement): void {
button.focus()
}
function moveFocus(button: HTMLElement, direction: -1 | 1): void {
const tree = button.closest<HTMLElement>('[role="tree"]')
/* v8 ignore next -- JsonTree attaches expander handlers only beneath its owning role=tree. */
if (tree === null) return
const expanders = Array.from(tree.querySelectorAll<HTMLElement>('[data-json-expander]'))
const current = expanders.indexOf(button)
/* v8 ignore next -- the current expander is a member of the queried non-empty set. */
if (current < 0 || expanders.length === 0) return
const next = (current + direction + expanders.length) % expanders.length
const nextExpander = expanders[next]
/* v8 ignore next -- modulo over the non-empty expander set always resolves a member. */
if (nextExpander !== undefined) claimFocus(nextExpander)
}
function NodeField({
field,
expandable,
onToggle,
}: {
field: string | undefined
expandable: boolean
onToggle: () => void
}) {
if (field === undefined) return null
return (
<span
className={clsx(css.label, expandable && css.clickableLabel)}
onClick={expandable ? onToggle : undefined}
>
{fieldText(field)}:
</span>
)
}
interface JsonTreeNodeProps {
field?: string
initialExpanded: boolean
lastElement: boolean
onClaimTabStop: (id: string) => void
onRowHover: (row: HTMLElement, target: RowTarget) => void
path: JsonPath
tabStopId: string | null
value: unknown
}
function JsonTreeNode({
field,
initialExpanded,
lastElement,
onClaimTabStop,
onRowHover,
path,
tabStopId,
value,
}: JsonTreeNodeProps) {
const contentsId = useId()
const expanderRef = useRef<HTMLSpanElement>(null)
const [expanded, setExpanded] = useState(initialExpanded)
const nodeId = pathId(path)
const container = isExpandableValue(value)
const entries = container ? entriesOf(value) : []
const expandable = entries.length > 0
const toggle = () => {
setExpanded(current => !current)
claimFocus(expanderRef.current as HTMLSpanElement)
}
const onExpanderKeyDown = (event: ReactKeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault()
setExpanded(event.key === 'ArrowRight')
return
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
moveFocus(event.currentTarget, event.key === 'ArrowUp' ? -1 : 1)
}
}
const row = (children: ReactNode, ariaExpanded?: boolean) => (
<div
className={css.row}
role="treeitem"
aria-expanded={ariaExpanded}
onMouseOver={(event) => {
event.stopPropagation()
onRowHover(event.currentTarget, { path, value })
}}
>
{children}
</div>
)
if (!container) {
return row((
<>
<NodeField field={field} expandable={false} onToggle={toggle} />
{primitiveValue(value)}
{!lastElement && <span className={css.punctuation}>,</span>}
</>
))
}
const [open, close] = bracketOf(value)
if (!expandable) {
return row((
<>
<NodeField field={field} expandable={false} onToggle={toggle} />
<span className={css.punctuation}>{open}</span>
<span className={css.punctuation}>{close}</span>
{!lastElement && <span className={css.punctuation}>,</span>}
</>
))
}
return row((
<>
<span
ref={expanderRef}
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
data-json-expander
role="button"
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
aria-expanded={expanded}
aria-controls={expanded ? contentsId : undefined}
tabIndex={tabStopId === nodeId ? 0 : -1}
onFocus={() => { onClaimTabStop(nodeId) }}
onClick={toggle}
onKeyDown={onExpanderKeyDown}
/>
<NodeField field={field} expandable onToggle={toggle} />
<span className={css.preview}>{previewValue(value, 0)}</span>
{!lastElement && <span className={css.punctuation}>,</span>}
{expanded && (
<ul id={contentsId} role="group" className={css.children}>
{entries.map(([key, item], index) => (
<JsonTreeNode
key={key}
field={key}
value={item}
path={[...path, Array.isArray(value) ? index : key]}
lastElement={index === entries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
onClaimTabStop={onClaimTabStop}
onRowHover={onRowHover}
/>
))}
</ul>
)}
</>
), expanded)
}
function formattedPath(path: JsonPath): string {
return path.reduce<string>((result, part) => {
if (typeof part === 'number') return `${result}[${String(part)}]`
return /^[A-Za-z_$][\w$]*$/.test(part)
? `${result}.${part}`
: `${result}[${JSON.stringify(part)}]`
}, '$')
}
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
if (mode === 'path') return formattedPath(target.path)
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
if (mode === 'json') return JSON.stringify(target.value)
if (typeof target.value === 'string') return target.value
if (typeof target.value === 'undefined') return 'undefined'
if (typeof target.value === 'bigint') return target.value.toString()
if (typeof target.value === 'symbol') return target.value.description ?? 'Symbol'
if (typeof target.value === 'function') return target.value.name || 'Function'
return JSON.stringify(target.value)
}
/** Props for the read-only, token-themed JSON tree. */
export interface JsonTreeProps {
/** Parsed JSON object or array. */
data: object | unknown[]
/** Accessible label for the tree. */
label?: string
/** Optional positioning class owned by the caller. */
className?: string | undefined
/** Whether JSON rows expose copy actions. */
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
}
/**
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
* @param props - Parsed data, accessible label, and display options.
* @returns A read-only JSON tree with an optionally fixed-open top level.
*/
export function JsonTree({
data,
label = 'JSON',
className,
copyable = true,
expandTopLevel = true,
}: JsonTreeProps) {
const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0
))
const firstExpandableEntry = rootEntries[firstExpandableIndex]
const initialTabStopId = expandTopLevel
? firstExpandableEntry === undefined
? null
: pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
: isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
const rootRef = useRef<HTMLDivElement>(null)
const activeRowRef = useRef<HTMLElement>()
const copyButtonRef = useRef<HTMLButtonElement>(null)
const copyMenuOpenRef = useRef(false)
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
const setActiveRow = (row: HTMLElement | undefined) => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = row
row?.setAttribute('data-json-copy-active', '')
}
const clearCopyTarget = () => {
setActiveRow(undefined)
setCopyTarget(undefined)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}
const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
const root = rootRef.current
/* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
if (root === null) throw new Error('JsonTree root is not mounted')
const rootRect = root.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
return {
left: rootRect.left + root.clientWidth - 26,
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
top: rowRect.top,
}
}
const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
const position = copyPosition(row)
setCopyTarget({ ...target, ...position })
}
const repositionCopyButton = (row: HTMLElement) => {
const position = copyPosition(row)
setCopyTarget((current) => {
/* v8 ignore next -- an active row and its copy target are installed together. */
if (current === undefined) return current
return { ...current, ...position }
})
}
useEffect(() => () => {
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
activeRowRef.current?.removeAttribute('data-json-copy-active')
}, [])
useEffect(() => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = undefined
copyMenuOpenRef.current = false
setCopyTarget(undefined)
setCopyState('idle')
setCopyMenuOpen(false)
setTabStopId(initialTabStopId)
}, [data, expandTopLevel, initialTabStopId])
useEffect(() => {
const reposition = () => {
const row = activeRowRef.current
if (row !== undefined) repositionCopyButton(row)
}
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [])
const handleRowHover = (row: HTMLElement, target: RowTarget) => {
if (!copyable || copyMenuOpenRef.current) return
if (activeRowRef.current === row) return
setActiveRow(row)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
positionCopyButton(row, target)
}
const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!copyable || copyMenuOpenRef.current) return
/* v8 ignore next -- browser mouse events delivered through React target an Element. */
if (!(event.target instanceof Element)) return
if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
}
const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
const row = activeRowRef.current
if (row !== undefined) repositionCopyButton(row)
}
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
/* v8 ignore next -- copy controls only render while their target exists. */
if (copyTarget === undefined) return
try {
await navigator.clipboard.writeText(copyText(copyTarget, mode))
setCopyState('copied')
} catch {
setCopyState('failed')
}
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
}
const [rootOpen, rootClose] = bracketOf(data)
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
return (
<div
ref={rootRef}
className={clsx(css.root, className)}
onMouseOver={handleRootMouseOver}
onMouseLeave={() => {
if (!copyMenuOpenRef.current) clearCopyTarget()
}}
onScroll={handleScroll}
>
{expandTopLevel
? (
<div className={css.expandedTopLevel}>
<div
className={clsx(css.row, css.topLevelBracket)}
data-json-root-row
onMouseOver={(event) => {
event.stopPropagation()
handleRowHover(event.currentTarget, { path: [], value: data })
}}
>
<span className={css.punctuation}>{rootOpen}</span>
</div>
<div
aria-label={label}
className={clsx(css.container, css.expandedTopLevelContainer)}
role="tree"
>
{rootEntries.map(([key, value], index) => (
<JsonTreeNode
key={key}
field={key}
value={value}
path={[Array.isArray(data) ? index : key]}
lastElement={index === rootEntries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
onClaimTabStop={setTabStopId}
onRowHover={handleRowHover}
/>
))}
</div>
<div className={clsx(css.row, css.topLevelBracket)}>
<span className={css.punctuation}>{rootClose}</span>
</div>
</div>
)
: (
<div aria-label={label} className={css.container} role="tree">
<JsonTreeNode
value={data}
path={[]}
lastElement
initialExpanded
tabStopId={tabStopId}
onClaimTabStop={setTabStopId}
onRowHover={handleRowHover}
/>
</div>
)}
{copyTarget !== undefined && (
<span
className={css.copyAnchor}
style={{ left: copyTarget.left, top: copyTarget.top }}
>
<Menu
open={copyMenuOpen}
compact
portal
align="end"
side={copyTarget.side}
anchor={(
<button
ref={copyButtonRef}
type="button"
className={css.copyButton}
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
copyMenuOpenRef.current = true
setCopyMenuOpen(true)
}}
>
{copyState === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
onSelect={(id) => {
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}}
onClose={clearCopyTarget}
getAnchorRect={() => (
copyButtonRef.current as HTMLButtonElement
).getBoundingClientRect()}
/>
</span>
)}
</div>
)
}

View File

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

View File

@@ -71,6 +71,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -81,7 +82,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
* by a hairline; they stay visible while the items above scroll.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -93,6 +94,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
side?: 'bottom' | 'top' | 'right'
portal?: boolean
closeOnPointerLeave?: boolean
compact?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -219,7 +221,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
@@ -246,7 +248,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
const list = open && (
<div
ref={listRef}
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ describe('Menu', () => {
render(
<Menu
open
compact
anchor={<span>trigger</span>}
items={[
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
@@ -186,6 +187,7 @@ describe('Menu', () => {
render(
<Menu
open
compact
anchor={<span>trigger</span>}
items={[
{ id: 'plain', label: 'Plain' },

View File

@@ -0,0 +1,339 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { JsonTree } from '@deepseek-ai/dsh-client-ui-primitives'
let writeText: ReturnType<typeof vi.fn>
beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('JsonTree', () => {
it('keeps the top level open and renders expandable value previews', () => {
render(
<JsonTree
label="Payload"
data={{
nested: { answer: 42 },
list: ['alpha', 'beta'],
}}
/>,
)
const tree = screen.getByRole('tree', { name: 'Payload' })
const rows = within(tree).getAllByRole('treeitem')
expect(rows).toHaveLength(2)
expect(rows[0]?.textContent).toBe('nested:{answer: 42},')
expect(rows[1]?.textContent).toBe('list:["alpha", "beta"]')
const expanders = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
expect(expanders[0]?.tabIndex).toBe(0)
expect(expanders[1]?.tabIndex).toBe(-1)
fireEvent.click(expanders[0] as HTMLElement)
expect(within(tree).getAllByRole('treeitem')).toHaveLength(3)
expect(screen.getByText('answer:')).toBeDefined()
expect(within(tree).getByRole('button', { name: 'Collapse JSON node' })).toBeDefined()
})
it('moves the single tab stop between visible expanders with arrow keys', () => {
render(
<JsonTree
expandTopLevel={false}
data={{
first: { nested: 1 },
second: { nested: 2 },
}}
/>,
)
const tree = screen.getByRole('tree', { name: 'JSON' })
const root = within(tree).getByRole('button', { name: 'Collapse JSON node' })
const children = within(tree).getAllByRole('button', { name: 'Expand JSON node' })
expect(root.tabIndex).toBe(0)
fireEvent.keyDown(root, { key: 'ArrowDown' })
expect(document.activeElement).toBe(children[0])
expect(root.tabIndex).toBe(-1)
expect(children[0]?.tabIndex).toBe(0)
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowRight' })
expect(children[0]?.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowLeft' })
expect(children[0]?.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(children[0] as HTMLElement, { key: 'Enter' })
fireEvent.keyDown(children[0] as HTMLElement, { key: 'ArrowUp' })
expect(document.activeElement).toBe(root)
fireEvent.keyDown(root, { key: 'ArrowUp' })
expect(document.activeElement).toBe(children[1])
})
it('copies an array element path without recovering data from rendered labels', async () => {
render(<JsonTree data={{ list: [{ value: 'x' }, 'tail'] }} />)
const tree = screen.getByRole('tree')
fireEvent.click(within(tree).getByRole('button', { name: 'Expand JSON node' }))
const arrayRow = within(tree).getAllByRole('treeitem')
.find(row => row.textContent?.startsWith('0:'))
expect(arrayRow).toBeDefined()
fireEvent.mouseOver(arrayRow as HTMLElement)
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
fireEvent.contextMenu(copyButton)
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy property path' }))
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('$.list[0]')
})
})
it('renders empty containers, JSON-adjacent primitives, and bounded deep previews', () => {
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
const date = new Date('2026-07-28T00:00:00.000Z')
const data = {
'': 'empty key',
nil: null,
text: 'quoted',
flag: true,
count: 3,
big: 4n,
date,
named: function named() {},
missing: undefined,
symbol: Symbol('token'),
emptyObject: {},
emptyArray: [],
primitivePreview: {
nil: null,
flag: false,
big: 9n,
missing: undefined,
},
exoticPreview: {
symbol: Symbol(),
named: function sample() {},
anonymous,
date,
},
wideObject: { a: 1, b: 2, c: 3, d: 4, e: 5 },
wideArray: [1, 2, 3, 4, 5, 6],
deep: { a: { b: { c: 1 } } },
}
render(<JsonTree copyable={false} data={data} />)
const text = screen.getByRole('tree').textContent
expect(text).toContain('"":\"empty key\"')
expect(text).toContain('nil:null')
expect(text).toContain('flag:true')
expect(text).toContain('count:3')
expect(text).toContain('big:4n')
expect(text).toContain('date:2026-07-28T00:00:00.000Z')
expect(text).toContain('named:function() { }')
expect(text).toContain('missing:undefined')
expect(text).toContain('symbol:Symbol(token)')
expect(text).toContain('emptyObject:{}')
expect(text).toContain('emptyArray:[]')
expect(text).toContain('primitivePreview:{nil: null, flag: false, big: 9, missing: undefined}')
expect(text).toContain('exoticPreview:{symbol: Symbol, named: sample, anonymous: Function, date: }')
expect(text).toContain('wideObject:{a: 1, b: 2, c: 3, d: 4, …}')
expect(text).toContain('wideArray:[1, 2, 3, 4, 5, …]')
expect(text).toContain('deep:{a: {b: {…}}}')
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
fireEvent.mouseOver(screen.getByRole('tree').parentElement as HTMLElement)
})
it('renders child commas and lets a clickable property label toggle its node', () => {
render(<JsonTree data={{ parent: { emptyObject: {}, emptyArray: [], scalar: 1, last: 2 } }} />)
fireEvent.click(screen.getByText('parent:'))
const tree = screen.getByRole('tree')
const rows = within(tree).getAllByRole('treeitem')
expect(rows.find(row => row.textContent === 'emptyObject:{},')).toBeDefined()
expect(rows.find(row => row.textContent === 'emptyArray:[],')).toBeDefined()
expect(rows.find(row => row.textContent === 'scalar:1,')).toBeDefined()
expect(rows.find(row => row.textContent === 'last:2')).toBeDefined()
fireEvent.click(screen.getByText('parent:'))
expect(within(tree).getAllByRole('treeitem')).toHaveLength(1)
})
it('assigns the initial array tab stop and supports an empty collapsible root', () => {
const first = render(<JsonTree data={['plain', { nested: true }]} />)
const tree = screen.getByRole('tree')
expect(tree.textContent).toContain('0:"plain"')
expect(within(tree).getByRole('button', { name: 'Expand JSON node' }).tabIndex).toBe(0)
first.unmount()
render(<JsonTree expandTopLevel={false} data={{}} />)
expect(screen.getByRole('tree').textContent).toBe('{}')
expect(screen.queryByRole('button', { name: /JSON node/ })).toBeNull()
})
it('copies primitive and object values in every menu mode', async () => {
const anonymous = Object.defineProperty(() => {}, 'name', { value: '' })
render(
<JsonTree
data={{
plain: 'hello',
'odd-key': 3,
object: { a: 1 },
missing: undefined,
big: 7n,
symbol: Symbol(),
symbolNamed: Symbol('token'),
named: function named() {},
anonymous,
}}
/>,
)
const tree = screen.getByRole('tree')
const row = (prefix: string) => {
const match = within(tree).getAllByRole('treeitem')
.find(item => item.textContent?.startsWith(prefix))
expect(match).toBeDefined()
return match as HTMLElement
}
const hover = (prefix: string) => {
fireEvent.mouseOver(row(prefix))
return screen.getByRole('button', { name: /Cop/ })
}
const select = (name: string) => {
const button = screen.getByRole('button', { name: /Cop/ })
fireEvent.contextMenu(button)
fireEvent.click(screen.getByRole('menuitem', { name }))
}
fireEvent.click(hover('plain:'))
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('hello') })
hover('odd-key:')
select('Copy property path')
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('$["odd-key"]') })
select('Copy JSON')
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
fireEvent.click(hover('odd-key:'))
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('3') })
fireEvent.click(hover('object:'))
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{\n "a": 1\n}') })
select('Copy compact JSON')
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('{"a":1}') })
for (const [prefix, expected] of [
['missing:', 'undefined'],
['big:', '7'],
['symbol:', 'Symbol'],
['symbolNamed:', 'token'],
['named:', 'named'],
['anonymous:', 'Function'],
] as const) {
fireEvent.click(hover(prefix))
await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(expected) })
}
})
it('reports clipboard failure, resets feedback, and clears a prior timer', async () => {
vi.useFakeTimers()
writeText.mockRejectedValue(new Error('denied'))
const view = render(<JsonTree data={{ value: 'x' }} />)
const row = screen.getByRole('treeitem')
fireEvent.mouseOver(row)
fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('button', { name: 'Copy failed' })).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Copy failed' }))
await act(async () => { await Promise.resolve() })
act(() => { vi.advanceTimersByTime(1_500) })
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
view.unmount()
})
it('keeps copy placement synchronized and clears stale targets', () => {
const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
const root = view.container.firstElementChild as HTMLElement
const tree = screen.getByRole('tree')
const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 })
Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 })
vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
bottom: 100,
height: 100,
left: 10,
right: 310,
top: 0,
width: 300,
x: 10,
y: 0,
toJSON: () => ({}),
})
vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({
bottom: 91,
height: 16,
left: 10,
right: 200,
top: 75,
width: 190,
x: 10,
y: 75,
toJSON: () => ({}),
})
fireEvent.mouseOver(firstRow)
const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px')
fireEvent.mouseOver(copyButton)
expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
fireEvent.mouseOver(firstRow)
fireEvent.scroll(root)
fireEvent.scroll(window)
fireEvent.resize(window)
fireEvent.contextMenu(copyButton)
fireEvent.mouseOver(secondRow)
fireEvent.mouseOver(root)
fireEvent.mouseLeave(root)
expect(screen.getByRole('menu')).toBeDefined()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
fireEvent.mouseOver(secondRow)
expect(screen.getByRole('button', { name: 'Copy value' })).toBeDefined()
fireEvent.mouseOver(root)
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
fireEvent.scroll(root)
view.rerender(<JsonTree data={{ replacement: 3 }} />)
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
})
it('copies the fixed root and clears it when the pointer leaves', async () => {
const view = render(<JsonTree data={{ value: 1 }} />)
const root = view.container.firstElementChild as HTMLElement
const openingBracket = root.querySelector<HTMLElement>('[data-json-root-row]')
expect(openingBracket).not.toBeNull()
fireEvent.mouseOver(openingBracket as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: 'Copy pretty JSON' }))
await waitFor(() => { expect(writeText).toHaveBeenCalledWith('{\n "value": 1\n}') })
fireEvent.mouseLeave(root)
expect(screen.queryByRole('button', { name: /Copy/ })).toBeNull()
})
})

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
const MARKDOWN = [
'# Release notes',
'',
'First **paragraph** with [a link](https://example.com) and ![diagram](diagram.png).',
'',
'- shipped',
'- `verified`',
'',
'```ts',
'const ready = true',
'```',
].join('\n')
describe('extractMarkdownPlainText', () => {
it('projects the complete GFM document without presentation syntax', () => {
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
'Release notes',
'',
'First paragraph with a link and diagram.',
'',
'shipped',
'verified',
'',
'const ready = true',
].join('\n'))
})
it('selects the first visible line or first semantic paragraph', () => {
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
.toBe('First paragraph with a link and diagram.')
})
it('preserves raw HTML while removing Markdown presentation markup', () => {
const block = [
'<background-task-complete id="trajectory-ui-watch">',
'Command: pnpm test',
'Exit code: 0',
'</background-task-complete>',
].join('\n')
expect(extractMarkdownPlainText(block)).toBe(block)
expect(extractMarkdownPlainText('**Status:** <span data-state="ok">ready</span>'))
.toBe('Status: <span data-state="ok">ready</span>')
expect(extractMarkdownPlainText(block, { mode: 'first-paragraph' }))
.toBe('<background-task-complete id="trajectory-ui-watch">')
})
it('projects GFM tables, references, hard breaks, and block structure', () => {
const markdown = [
'> first\\',
'> second with ![diagram][asset] and <span>visible</span>',
'',
'---',
'',
'| Name | Value |',
'| --- | --- |',
'| alpha | `1` |',
'',
'[asset]: diagram.png',
].join('\n')
expect(extractMarkdownPlainText(markdown)).toBe([
'first second with diagram and <span>visible</span>',
'',
'Name\tValue',
'alpha\t1',
].join('\n'))
})
})

View File

@@ -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 packages/client/ui-trajectory/README.md
README.md: 9e7dee8d5572baad13d7598d1fbde7b042dd5ab4
README.zh.md: 056827d0110b4360791f1ae08c9bf6f62c00f049
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience
@@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
- **In-flight Time stays blank** — `partial` / `runningCalls` rows show their running state without a fabricated duration until a live clock policy lands, so the Overview renders a start marker rather than inventing a live span; record and timeline selection are intentionally local to Trajectory; anchor deep-linking remains deferred.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
轨迹轮次列表界面框架(吸顶 TurnMessage·Step 分组/步骤单元格)及 Waterfall瀑布式事件占位符这是纯消费方最小插件范例(向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验
@@ -14,4 +14,4 @@
## 已知限制与暂缓事项
- **进行中时Time 保持空白**`partial``runningCalls`在实时钟策略落地前渲染为 `—`;选中样式仅在当前视图内部生效(未连接到聊天详情);锚点深链接仍暂缓实现。
- **进行中时Time 保持空白**`partial``runningCalls`会显示运行状态,但在实时钟策略落地前不会虚构耗时,因此 Overview 区域只渲染开始标记,而不会杜撰实时跨度;记录选择与时间线选择有意保持在 Trajectory 内部;锚点深链接仍暂缓实现。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-trajectory",
"description": "Trajectory/Waterfall placeholder views: pure-consumer plugin registering into the conversation ViewMap (no service)",
"description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -33,13 +33,18 @@
"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",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -46,14 +46,40 @@
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);
}
.tagContext {
color: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
background: var(--dsw-alias-state-success-tertiary);
}
.tagMessage {
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
background: var(--dsw-specific-bubble);
color: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
background: color-mix(
in srgb,
color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 55%,
var(--dsw-alias-state-error-secondary)
) 15%,
var(--dsw-alias-bg-layer-1)
);
}
.tagTool {
@@ -64,8 +90,16 @@
/* run_code sub-dispatch cells: the business tint plus an indent so the
nesting under the parent Tool cell reads at a glance. */
.tagSubtool {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
color: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-tertiary) 58%,
var(--dsw-alias-bg-layer-1)
);
}
.root[data-kind='subtool'] {

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
.root {
padding: 4px 16px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
border-bottom: 1px solid var(--dsw-alias-border-l2);
}

View File

@@ -1,21 +0,0 @@
// TrajectoryStatsHeader: span totals row rendered at the top of both
// placeholder view bodies (chrome dissolved into the views — the header is
// part of what these views ARE, not registration metadata). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row is quiet
// during streaming.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { deriveSpans, deriveSpanStats } from './spans.ts'
import css from './TrajectoryStatsHeader.module.css'
/** Props: the conversation-snapshot selector hook (handed down by the view body). */
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
if (stats.turns === 0) return null
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
.root {
flex: none;
border-bottom: 1px solid var(--dsw-alias-border-l2);
user-select: none;
}
.plot {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
height: 50px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-2);
}
.labels {
position: relative;
border-right: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
font-size: 10px;
line-height: 1;
}
.labels span {
position: absolute;
right: 3px;
display: flex;
align-items: center;
justify-content: flex-end;
height: 8px;
text-align: right;
}
.labels span:nth-child(1) {
top: 7px;
}
.labels span:nth-child(2) {
top: 21px;
}
.labels span:nth-child(3) {
top: 35px;
}
.track {
position: relative;
overflow: hidden;
cursor: crosshair;
touch-action: none;
}
.empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
.track:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.lanes {
position: absolute;
z-index: 2;
inset: 7px 0;
}
.turnBoundaries {
position: absolute;
z-index: 3;
inset: 0;
pointer-events: none;
}
.turnBoundary {
position: absolute;
top: 0;
bottom: 0;
left: var(--trajectory-turn-left);
width: 1px;
background: var(--dsw-alias-border-l2);
}
.span {
position: absolute;
top: calc(var(--trajectory-span-lane) * 14px);
left: calc(var(--trajectory-span-left) + 1px);
width: max(2px, calc(var(--trajectory-span-width) - 2px));
height: 8px;
min-width: 2px;
border-radius: 1px;
background: var(--dsw-alias-label-secondary);
opacity: 0.78;
}
.span[data-timeline-span='user'] {
background: var(--dsw-alias-state-business-primary);
}
.span[data-timeline-span='context'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-success-primary) 68%,
var(--dsw-alias-label-secondary)
);
}
.span[data-timeline-span='message'] {
background: color-mix(
in srgb,
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
var(--dsw-alias-state-error-secondary)
);
}
.span[data-timeline-span='tool'] {
background: var(--dsw-alias-state-warn-label);
}
.span[data-timeline-span='subtool'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-label) 62%,
var(--dsw-alias-label-tertiary)
);
}
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
}
.span[data-selected='false'] {
opacity: 0.2;
}
.span[data-current='true'] {
z-index: 1;
opacity: 1;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px var(--dsw-alias-state-business-primary);
}
.span[data-search-match='false'] {
opacity: 0.14;
}
.selection {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 12%,
transparent
);
box-shadow:
-100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent),
100vw 0 0 100vw color-mix(in srgb, var(--dsw-alias-bg-layer-1) 58%, transparent);
pointer-events: none;
}
.selectionEdges {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
left: var(--trajectory-selection-left);
width: var(--trajectory-selection-width);
min-width: 1px;
pointer-events: none;
}
.hoverLine {
position: absolute;
z-index: 4;
top: 0;
bottom: 0;
left: clamp(
0px,
calc(var(--trajectory-hover-left) - 1px),
calc(100% - 2px)
);
width: 2px;
background: var(--dsw-alias-state-business-primary);
pointer-events: none;
}
.selectionEdges::before,
.selectionEdges::after {
position: absolute;
top: 0;
bottom: 0;
width: 3px;
background: var(--dsw-alias-state-business-primary);
content: '';
}
.selectionEdges::before {
left: 0;
}
.selectionEdges::after {
right: 0;
}
.selectionEdges[data-dragging='true']::before,
.selectionEdges[data-dragging='true']::after {
width: 2px;
}
.selection[data-dragging='true'] {
background: color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 18%,
transparent
);
}

View File

@@ -0,0 +1,372 @@
/** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent, type WheelEvent,
} from 'react'
import type { TrajectoryTurnModel } from './layout.ts'
import {
deriveTrajectoryTimeline,
formatTimelineOffset,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
interface FractionRange {
start: number
end: number
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
onRecordFocus?: (index: number) => void
}
function orderedRange(left: number, right: number): FractionRange {
return left <= right ? { start: left, end: right } : { start: right, end: left }
}
function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value))
}
function centeredRange(center: number, width: number): FractionRange {
const clampedWidth = Math.min(1, Math.max(0, width))
const start = Math.min(
Math.max(center - clampedWidth / 2, 0),
1 - clampedWidth,
)
return { start, end: start + clampedWidth }
}
function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
)
}
function LaneLabels() {
return (
<div className={css.labels} aria-hidden="true">
<span>Input</span>
<span>Model</span>
<span>Tools</span>
</div>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
const durationByIndex = useMemo(
() => new Map(turns.flatMap(turn =>
turn.groups.flatMap(group =>
group.cells.flatMap(cell =>
cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds)
? []
: [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const],
),
),
)),
[turns],
)
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const [hover, setHover] = useState<number | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
useEffect(() => {
if (
model !== null
&& range !== null
&& (range.end < model.start || range.start > model.end)
) {
onRangeChange(null)
}
}, [model, onRangeChange, range])
useEffect(() => {
if (model === null) return
setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end)
? null
: current)
}, [model])
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min(
fullDuration,
Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
)
const viewportStart = model === null || viewport === null
? model?.start ?? 0
: Math.min(
Math.max(viewport.start, model.start),
model.end - viewportDuration,
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed
const activeRange = draft === null
? range
: {
start: domainStart + draft.start * domainDuration,
end: domainStart + draft.end * domainDuration,
}
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
</div>
</div>
</section>
)
}
const minimumSelectionFraction = Math.min(
1,
fullDuration / domainDuration / model.spans.length,
)
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
const rect = event.currentTarget.getBoundingClientRect()
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
}
const commit = (fraction: FractionRange) => {
onRangeChange({
start: domainStart + fraction.start * domainDuration,
end: domainStart + fraction.end * domainDuration,
})
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
setHover(anchor)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const fraction = fractionAt(event)
setHover(fraction)
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fraction))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const point = fractionAt(event)
const selected = orderedRange(drag.anchor, point)
setHover(point)
dragRef.current = null
setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction
? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction,
)
: selected
commit(committedRange)
if (click) {
const timelinePoint = domainStart + selected.start * domainDuration
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint
: timelinePoint > candidate.end ? timelinePoint - candidate.end : 0
const spanDistance = timelinePoint < span.start
? span.start - timelinePoint
: timelinePoint > span.end ? timelinePoint - span.end : 0
return spanDistance < candidateDistance ? span : candidate
})
onRecordFocus?.(nearest.index)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape' || range === null) return
event.preventDefault()
onRangeChange(null)
}
const onPointerCancel = () => {
dragRef.current = null
setDraft(null)
setHover(null)
}
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
}
const anchorTime = domainStart + anchorFraction * domainDuration
const nextStart = Math.min(
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
return (
<section className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div
className={css.track}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerCancel}
onPointerLeave={() => {
if (dragRef.current === null) setHover(null)
}}
onDoubleClick={(event) => {
event.preventDefault()
onRangeChange(null)
}}
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && draft === null && (
<div
className={css.hoverLine}
aria-hidden="true"
style={{
'--trajectory-hover-left': `${hover * 100}%`,
} as CSSProperties}
/>
)}
{visibleRange !== null && (
<>
<div
className={css.selection}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true"
style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties}
/>
<div
className={css.selectionEdges}
data-dragging={draft === null ? undefined : 'true'}
aria-hidden="true"
style={{
'--trajectory-selection-left': `${visibleRange.start * 100}%`,
'--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
} as CSSProperties}
/>
</>
)}
<div className={css.turnBoundaries} aria-hidden="true">
{model.turnBoundaries
.slice(1)
.filter(boundary =>
boundary.time >= domainStart
&& boundary.time <= domainStart + domainDuration)
.map(boundary => (
<span
className={css.turnBoundary}
data-turn={boundary.turn}
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
const durationMs = durationByIndex.get(span.index)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}
data-selected={activeRange === null
? undefined
: span.start <= activeRange.end && span.end >= activeRange.start
? 'true'
: 'false'}
key={span.index}
title={durationMs === undefined
? span.label
: `${span.label} · ${formatTimelineOffset(durationMs)}`}
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
'--trajectory-span-lane': span.lane,
} as CSSProperties}
/>
)
})}
</div>
</div>
</div>
</section>
)
})

View File

@@ -0,0 +1,219 @@
.root {
position: sticky;
top: 0;
z-index: 4;
box-sizing: border-box;
width: 100%;
height: var(--dsh-trajectory-toolbar-height);
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
}
.inner {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 6px;
gap: 8px;
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
}
.toggle {
display: inline-flex;
flex: none;
align-items: center;
height: 20px;
padding: 0 7px;
gap: 4px;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.toggle:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.toggle[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.toggle:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.toggleIcon {
flex: none;
width: 12px;
height: 12px;
stroke: currentColor;
stroke-width: 1.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.control {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 88px;
height: 20px;
padding: 0 5px;
gap: 4px;
border: 0;
border-radius: 0;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.control[hidden] {
display: none;
}
.control:hover:not(:disabled),
.control[aria-checked='true'],
.control[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
}
.control:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.control:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.controlTrack {
position: relative;
display: inline-block;
flex: none;
width: 20px;
height: 10px;
border-radius: 5px;
background: var(--dsw-alias-border-l2);
transition: background-color 120ms var(--ds-ease-in-out);
}
.controlThumb {
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dsw-alias-bg-layer-1);
transition: transform 120ms var(--ds-ease-in-out);
}
.controlTrack[data-on='true'] {
background: var(--dsw-alias-state-business-primary);
}
.controlTrack[data-on='true'] .controlThumb {
transform: translateX(10px);
}
.action {
display: inline-flex;
flex: none;
align-items: center;
height: 20px;
padding: 0 5px;
gap: 4px;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-tertiary);
background: transparent;
cursor: pointer;
font: var(--dsw-font-xxs-12);
}
.action:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.action:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 1px;
}
.action:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.actionIcon {
color: var(--dsw-alias-label-tertiary);
font: 14px/14px var(--ds-font-family-code);
}
.search {
display: flex;
flex: 0 1 164px;
align-items: center;
min-width: 84px;
height: 22px;
margin-left: auto;
padding: 0 6px;
gap: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
color: var(--dsw-alias-label-caption);
background: var(--dsw-alias-bg-layer-2);
}
.search:hover {
border-color: var(--dsw-alias-label-caption);
}
.search:focus-within {
border-color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-bg-layer-1);
}
.searchIcon {
flex: none;
}
.searchInput {
min-width: 0;
width: 100%;
padding: 0;
border: 0;
outline: 0;
color: var(--dsw-alias-label-primary);
background: transparent;
font: var(--dsw-font-xxs-12);
}
.searchInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.searchInput::-webkit-search-cancel-button {
width: 12px;
height: 12px;
cursor: pointer;
}

View File

@@ -0,0 +1,131 @@
/** Trajectory toolbar: timeline and ledger fold controls. */
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TrajectoryToolbar.module.css'
export interface TrajectoryToolbarProps {
/** Whether timeline blocks use recorded durations instead of equal widths. */
actualDuration: boolean
/** Select recorded-duration or equal-width blocks. */
onActualDurationChange: (actualDuration: boolean) => void
/** Whether recorded timing retains idle gaps between user turns. */
actualTime: boolean
/** Select complete wall-clock timing or idle-compressed timing. */
onActualTimeChange: (actualTime: boolean) => void
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
allTurnsCollapsed: boolean
/** Fold or expand every collapsible turn. */
onToggleAllTurns: () => void
/** Number of assistant messages followed by tool calls. */
collapsibleAssistants: number
/** Whether every collapsible assistant's tool calls are currently folded. */
allAssistantsCollapsed: boolean
/** Fold or expand tool calls under every collapsible assistant. */
onToggleAllAssistants: () => void
/** Current live ledger search query. */
searchQuery: string
/** Update the live ledger search query. */
onSearchQueryChange: (query: string) => void
}
/**
* Render the sticky trajectory toolbar.
* @param props - rendered counts and whole-list fold state.
* @returns the toolbar element.
*/
export function TrajectoryToolbar({
actualDuration,
onActualDurationChange,
actualTime,
onActualTimeChange,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
collapsibleAssistants,
allAssistantsCollapsed,
onToggleAllAssistants,
searchQuery,
onSearchQueryChange,
}: TrajectoryToolbarProps) {
return (
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
<div className={css.inner}>
<div className={css.actions}>
<button
type="button"
className={css.toggle}
aria-label="Use actual duration"
aria-pressed={actualDuration}
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
onClick={() => { onActualDurationChange(!actualDuration) }}
>
<svg
className={css.toggleIcon}
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="5.25" />
<path d="M8 4.75V8l2.25 1.5" />
</svg>
Duration
</button>
<button
type="button"
className={css.control}
role="switch"
aria-checked={actualTime}
hidden
onClick={() => { onActualTimeChange(!actualTime) }}
>
<span>Actual time</span>
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
<span className={css.controlThumb} />
</span>
</button>
<button
type="button"
className={css.action}
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
aria-pressed={allTurnsCollapsed}
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
disabled={collapsibleTurns === 0}
onClick={onToggleAllTurns}
>
<span className={css.actionIcon} aria-hidden="true">
{allTurnsCollapsed ? '⊞' : '⊟'}
</span>
Turns
</button>
<button
type="button"
className={css.action}
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
aria-pressed={allAssistantsCollapsed}
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
disabled={collapsibleAssistants === 0}
onClick={onToggleAllAssistants}
>
<span className={css.actionIcon} aria-hidden="true">
{allAssistantsCollapsed ? '⊞' : '⊟'}
</span>
Calls
</button>
</div>
<div className={css.search}>
<IconSearchOutline16 size={11} className={css.searchIcon} />
<input
type="search"
className={css.searchInput}
aria-label="Search trajectory"
placeholder="Search"
value={searchQuery}
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,41 +1,509 @@
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryCell } from './TrajectoryCell.tsx'
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
SessionHistoryFace,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
} from './context-branches.ts'
import {
TrajectoryTable,
type TrajectoryRequestNumber,
type TrajectoryUsage,
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import css from './views.module.css'
export function TrajectoryView({ useSession }: ConvViewProps) {
const nodes = useSession(s => s.nodes)
const partial = useSession(s => s.partial)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const turns = useMemo(
() => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }),
[nodes, partial, runningCalls, codeDispatches],
)
if (turns.length === 0) {
return <div className={css.root}><p className={css.empty}></p></div>
const EMPTY_IDS: ReadonlySet<number> = new Set()
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
hooks: { history: SessionHistoryFace }
loadAllHistory: (signal: AbortSignal) => Promise<void>
}
interface UsageLike {
inputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
const usage = value as UsageLike | undefined
if (usage === undefined) return undefined
return {
...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }),
...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }),
...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }),
...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }),
...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }),
}
}
function addUsage(
total: TrajectoryUsage | undefined,
usage: TrajectoryUsage | undefined,
): TrajectoryUsage | undefined {
if (usage === undefined) return total
return {
...(total?.input === undefined && usage.input === undefined
? {}
: { input: (total?.input ?? 0) + (usage.input ?? 0) }),
...(total?.cacheRead === undefined && usage.cacheRead === undefined
? {}
: { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }),
...(total?.cacheWrite === undefined && usage.cacheWrite === undefined
? {}
: { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }),
...(total?.output === undefined && usage.output === undefined
? {}
: { output: (total?.output ?? 0) + (usage.output ?? 0) }),
...(total?.reasoning === undefined && usage.reasoning === undefined
? {}
: { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }),
}
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function searchMatches(
turns: ReturnType<typeof deriveTrajectoryLayout>,
query: string,
): ReadonlySet<number> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<number>()
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
const text = [
`turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
cell.text,
cell.inputDetail,
cell.outputDetail,
cell.thinkingDetail,
cell.schemaDetail,
cell.result,
cell.callId,
...blocks.flatMap(block => [
block.type,
block.content,
block.callId,
block.toolName,
block.imageAlt,
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
].filter((value): value is string => typeof value === 'string')
.join('\n')
.toLocaleLowerCase()
if (terms.every(term => text.includes(term))) matches.add(cell.index)
}
}
}
return matches
}
export function TrajectoryView({
useHistory, loadAllHistory,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
range: TrajectoryTimeRange
} | null>(null)
const [actualDuration, setActualDuration] = useState(false)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
)
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 = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of inspection.interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
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
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
}
for (const node of nodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const requestsByStep = new Map(
requests
.filter(request => request.purpose === 'assistant')
.map(request => [
`${request.turn}\u0000${request.step}`,
request,
]),
)
const orderedRequests = [
...requests.map(request => ({
seq: request.startSeq,
request,
node: request.purpose === 'assistant'
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
: undefined,
})),
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
requestsByStep.has(key)
? []
: [{
seq: node.seq,
request: undefined,
node,
}],
),
].sort((left, right) => left.seq - right.seq)
const numbered: TrajectoryRequestNumber[] = []
let cumulativeUsage: TrajectoryUsage | undefined
for (const [index, entry] of orderedRequests.entries()) {
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
cumulativeUsage = addUsage(cumulativeUsage, usage)
if (entry.request?.purpose !== 'compaction') {
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,
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: request.provenance.provider }),
...(request.provenance?.model === undefined
? {}
: { model: request.provenance.model }),
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
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,
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return numbered
}, [
contexts, currentBranch.latest.prompt, nodes, partial, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
timelineFocusIndexes !== null
&& !timelineFocusIndexes.has(index)
) {
setTimelineSelection(null)
}
}, [timelineFocusIndexes])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const ledger = ledgerRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const first = focusedRows.at(0)
const last = focusedRows.at(-1)
if (first === undefined || last === undefined) return
const focusHeight =
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
if (focusHeight > ledger.clientHeight) {
if (typeof first.scrollIntoView === 'function') {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return
}
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [timelineFocusIndexes])
const collapsibleTurnIds = useMemo(
() => turns
.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],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
if (cell?.kind !== 'message') continue
const next = cells[i + 1]
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
}
}
return ids
}, [turns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
const toggleTurn = (turn: number) => {
setCollapsedTurns((current) => {
const collapsed = new Set(current)
if (collapsed.has(turn)) collapsed.delete(turn)
else collapsed.add(turn)
return collapsed
})
}
const toggleAllTurns = () => {
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)
}
return collapsed
})
}
const toggleAssistant = (index: number) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
return collapsed
})
}
const toggleAllAssistants = () => {
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)
}
return collapsed
})
}
return (
<div className={css.root}>
{turns.map(turn => (
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
{turn.groups.flatMap(group => [
<TrajectoryGroupHeader
key={`${group.title}-h`}
title={group.title}
{...(group.description !== undefined ? { description: group.description } : {})}
/>,
...group.cells.map(cell => (
<TrajectoryCell key={cell.index} {...cell} />
)),
])}
</TrajectoryTurn>
))}
<TrajectoryToolbar
actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => {
setActualDuration(nextActualDuration)
setTimelineSelection(null)
}}
actualTime={actualTime}
onActualTimeChange={(nextActualTime) => {
setActualTime(nextActualTime)
setTimelineSelection(null)
}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
collapsibleAssistants={collapsibleAssistantIds.length}
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
mode={timelineMode}
range={timelineRange}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
/>
<div ref={ledgerRef} className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
requestNumbers={requestNumbers}
turns={turns}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
/>
</div>
</div>
)
}

View File

@@ -1,75 +0,0 @@
// WaterfallView: span stats header over per-turn node-count lanes (P-I
// stand-in for duration lanes; deviation ledger #3). run_code turns
// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair
// carries per-sub-call wall time, so each sub-span's width is its real
// duration against the parent turn's dispatch window.
import { useMemo } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { deriveSpans, deriveSubSpans } from './spans.ts'
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
import css from './views.module.css'
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
const PX_PER_NODE = 14
const MIN_BAR_PX = 8
/** Sub-span lane width budget (the parent window scales into this). */
const SUB_LANE_PX = 220
/** Optional density override (test/standalone knob; the register site passes nothing). */
export interface WaterfallExtraProps {
/** Bar-lane density in px per node; defaults to 14. */
pxPerNode?: number
}
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
const scale = pxPerNode ?? PX_PER_NODE
const nodes = useSession(s => s.nodes)
const codeDispatches = useSession(s => s.codeDispatches)
const spans = useMemo(() => deriveSpans(nodes), [nodes])
const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches])
if (spans.length === 0) return <div className={css.root}><p className={css.empty}></p></div>
return (
<>
<TrajectoryStatsHeader useSession={useSession} />
<div className={css.root}>
{spans.map((span, i) => (
<div key={span.turn}>
<div className={css.row} style={{ paddingLeft: i * 12 }}>
<span className={css.turnTag}>turn {span.turn}</span>
<span
className={css.bar}
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
title={`${span.nodes} nodes`}
/>
{span.calls > 0 && (
<span
className={`${css.bar} ${css.barCalls}`}
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
title={`${span.calls} tool calls`}
/>
)}
</div>
{(subSpans.get(span.turn) ?? []).map(lane => (
<div key={lane.callId} className={css.subRow} data-subspan style={{ paddingLeft: i * 12 + 24 }}>
<span className={css.subTag}>{lane.name}</span>
<span
className={`${css.bar} ${css.barSub}`}
data-timing={lane.timing}
style={{
marginLeft: Math.round(lane.offsetFraction * SUB_LANE_PX),
width: Math.max(Math.round(lane.widthFraction * SUB_LANE_PX), 4),
}}
title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/>
</div>
))}
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,113 @@
/** Rewind-delimited trajectory branches assembled across surface rewrites. */
import type {
ConversationContext, ConversationNode, RequestView,
} 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[]
/** Seq that opened this branch; earlier requests require retained surface provenance. */
startSeq: number
/** Exact pre-rewind surface records inherited by this branch. */
retainedSurfaceSeqs: ReadonlySet<number>
}
interface MutableBranch {
id: number
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
startSeq: number
retainedSurfaceSeqs: Set<number>
}
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 retainedSurfaceSeqs = new Set(
context.nodes
.filter(node =>
context.originSeq !== undefined && node.seq < context.originSeq,
)
.map(node => node.seq),
)
const inheritedNodes = previous === undefined
? []
: [...previous.nodes.values()].filter(node =>
retainedSurfaceSeqs.has(node.seq),
)
mutable.push({
id: context.id,
contexts: [context],
latest: context,
nodes: new Map(
[...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))]
.map(node => [node.seq, node]),
),
startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY,
retainedSurfaceSeqs,
})
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),
startSeq: branch.startSeq,
retainedSurfaceSeqs: branch.retainedSurfaceSeqs,
}))
}
/**
* Test whether a provider request belongs to one rewind branch.
* @param branch - Branch carrying exact inherited surface provenance.
* @param request - Provider request to classify.
* @returns Whether the request began on this branch or produced a retained surface record.
*/
export function trajectoryBranchContainsRequest(
branch: TrajectoryContextBranch,
request: RequestView,
): boolean {
if (request.startSeq >= branch.startSeq) return true
return (
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)
}

View File

@@ -1,13 +1,13 @@
/**
* Browser trajectory plugin contributing two entries to the conversation
* view slot without defining a service.
* Browser trajectory plugin contributing one entry to the conversation view
* slot without defining a service.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { TrajectoryView } from './TrajectoryView.tsx'
import { WaterfallView } from './WaterfallView.tsx'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -16,17 +16,25 @@ import { WaterfallView } from './WaterfallView.tsx'
* into an undeclared slot throws — service waiting is what orders this
* apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'sessionHistory']
/**
* Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs).
* Client plugin body: register the trajectory view tab. The registration
* rides the slot service's effect wrapper, so plugin unload removes the tab.
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
ctx.slots.register(
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
ctx.slots.register(
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
order: 10,
label: 'Trajectory',
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history },
loadAllHistory: signal => history.loadAll(signal),
}
},
}, TrajectoryView)
}

View File

@@ -3,12 +3,19 @@
* own-duration times, in-flight partial/runningCalls, and group descriptions.
*/
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -28,12 +35,16 @@ export interface TrajectoryLayoutInput {
nodes: ConversationSnapshot['nodes']
partial: ConversationSnapshot['partial']
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
inputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
}
@@ -46,15 +57,85 @@ interface LaidCell {
callId?: string
}
interface LaidGroup {
title: string
laid: LaidCell[]
}
interface TurnBucket {
groups: LaidGroup[]
}
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
>
type OrderedLayoutEntry =
| {
kind: 'node'
seq: number
node: ConversationSnapshot['nodes'][number]
nodeIndex: number
}
| {
kind: 'compaction'
seq: number
request: RequestView
}
| {
kind: 'system'
seq: number
request: RequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: RequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
return entry.kind === 'system' && entry.change.kind === 'initial'
? Number.NEGATIVE_INFINITY
: entry.seq
}
function inputCellDetail(node: InputNode): Pick<
TrajectoryCellProps,
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
> {
return {
text: summarizeContent(node.content),
sourceSeq: node.seq,
messageSource: node.source,
inputDetail: detailContent(node.content),
sourceBlocks: node.content.map(block => sourceBlock(block)),
timeSeconds: 0,
startedAt: finiteTime(node.time),
}
}
/**
* 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, codeDispatches } = input
const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
if (startedAt !== null) callStartById.set(result.callId, startedAt)
}
for (const call of runningCalls) {
const startedAt = finiteTime(call.time)
if (startedAt !== null) callStartById.set(call.callId, startedAt)
}
const turns = new Map<number, TurnBucket>()
let index = 0
let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null
@@ -62,26 +143,171 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const bucket = (turn: number) => {
let entry = turns.get(turn)
if (entry === undefined) {
entry = { message: [], steps: new Map() }
entry = { groups: [] }
turns.set(turn, entry)
}
return entry
}
const pushMessage = (turn: number, laid: LaidCell) => {
bucket(turn).message.push(laid)
const groups = bucket(turn).groups
const last = groups.at(-1)
if (last?.title === 'Message') {
last.laid.push(laid)
return
}
groups.push({ title: 'Message', laid: [laid] })
}
const pushStep = (turn: number, step: number, laid: LaidCell) => {
const steps = bucket(turn).steps
const list = steps.get(step) ?? []
list.push(laid)
steps.set(step, list)
const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
if (laid.length === 0) return
const groups = bucket(turn).groups
const title = `Step ${step}`
const existing = groups.find(group => group.title === title)
if (existing !== undefined) {
existing.laid.push(...laid)
return
}
groups.push({ title, laid: [...laid] })
}
for (let i = 0; i < nodes.length; i++) {
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,
})),
...requests
.filter(request => request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
? []
: [{
kind: 'system' as const,
seq: request.promptChange.seq,
request,
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.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, request } = entry
const turn = change.kind === 'initial'
? firstVisibleTurn(nodes, partial)
: enclosingPromptTurn(nodes, change.seq, partial)
pushMessage(turn, {
absTime: finiteTime(change.time),
cell: {
index: ++index,
kind: 'system',
text: promptChangeLabel(change),
sourceSeq: change.seq,
...(request.prompt === undefined ? {} : { promptDetail: request.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.
@@ -91,19 +317,22 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index, kind: 'user', text: summarizeContent(node.content),
timeSeconds: 0,
index: ++index,
kind: 'user',
...inputCellDetail(node),
opensTurn: node.kind === 'user',
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches)
for (const laid of laidList) {
if (node.step > 0) pushStep(node.turn, node.step, laid)
else pushMessage(node.turn, laid)
}
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
)
if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid)
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -111,30 +340,47 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'context') {
// No trajectory cell, but the surface still advances the duration cursor.
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'context',
...inputCellDetail(node),
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
const toolName = node.call?.name
pushStep(0, 1, {
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
cell: {
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node),
outputBlocks: node.content.map(block => sourceBlock(block)),
result: summarizeResult(node),
callId: node.callId,
isError: node.isError,
timeSeconds: durationSeconds(node.time, node.callTime),
startedAt: finiteTime(node.callTime),
},
})
}]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
pushStep(0, 1, laid)
laidList.push(laid)
index = laid.cell.index
}
pushStep(0, 1, laidList)
}
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
}
@@ -145,11 +391,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
for (const laid of laidList) {
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
else pushMessage(partial.turn, laid)
}
const laidList = expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
{ streaming: true },
)
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1]
if (last !== undefined) index = last.cell.index
}
@@ -157,7 +408,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
const seenCalls = collectCallIds(turns)
for (const call of runningCalls) {
if (seenCalls.has(call.callId)) continue
pushStep(call.turn, call.step > 0 ? call.step : 1, {
const laidList: LaidCell[] = [{
absTime: null,
toolName: call.name,
callId: call.callId,
@@ -165,63 +416,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'tool',
text: summarizeCall(call.name, call.argsRaw),
inputDetail: call.argsRaw,
callId: call.callId,
timeSeconds: null,
startedAt: finiteTime(call.time),
},
})
}]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
pushStep(call.turn, call.step > 0 ? call.step : 1, laid)
laidList.push(laid)
index = laid.cell.index
}
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.
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
message: [],
steps: new Map(),
})
const emptyTurn = (): TurnBucket => ({ groups: [] })
const first = turns.get(1) ?? emptyTurn()
first.message = [...prologue.message, ...first.message]
for (const [step, cells] of prologue.steps) {
const existing = first.steps.get(step) ?? []
first.steps.set(step, [...cells, ...existing])
}
first.groups = [...prologue.groups, ...first.groups]
turns.set(1, first)
}
for (const entry of turns.values()) {
for (const group of entry.groups) {
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
}
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
): void {
if (laid.callId === undefined || callSchemas === undefined) return
const schema = callSchemas.get(laid.callId)
if (schema === undefined) return
laid.cell.schemaDetail = JSON.stringify(schema, null, 2)
}
function toTurnModel(
turn: number,
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
entry: TurnBucket,
): TrajectoryTurnModel {
const groups: TrajectoryGroupModel[] = []
if (entry.message.length > 0) {
const description = groupDescription(entry.message)
groups.push({
title: 'Message',
...(description !== undefined ? { description } : {}),
cells: entry.message.map(l => l.cell),
})
}
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
const laid = entry.steps.get(step) ?? []
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
const description = groupDescription(laid)
groups.push({
title: `Step ${step}`,
return {
title,
...(description !== undefined ? { description } : {}),
cells: laid.map(l => l.cell),
})
}
}
})
return { turn, groups }
}
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = []
// Tool rows contribute start (absTime) and end (start + own duration) so a
@@ -256,8 +511,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded}s`
return `${rounded.toFixed(1)}s`
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -267,8 +522,8 @@ function durationSeconds(later: number, earlier: number | null): number | null {
}
/** Epoch-ms usable as an absolute time, else null. */
function finiteTime(time: number): number | null {
return Number.isFinite(time) ? time : null
function finiteTime(time: number | null | undefined): number | null {
return typeof time === 'number' && Number.isFinite(time) ? time : null
}
function expandAssistant(
@@ -276,66 +531,175 @@ function expandAssistant(
startIndex: number,
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
const streaming = opts?.streaming === true
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
const recordedStart = finiteTime(node.timing?.stepStartTime)
const messageDuration = streaming
? null
: durationSeconds(node.time, recordedStart ?? prevAbsTime)
const nodeAbs = streaming ? null : finiteTime(node.time)
let usageAttached = false
const messageText = node.blocks
.filter(block => block.kind === 'text' && (!streaming || block.text !== ''))
.map(block => block.kind === 'text' ? block.text : '')
.join('\n\n')
const thinkingText = node.blocks
.filter(block => block.kind === 'reasoning' && (!streaming || block.text !== ''))
.map(block => block.kind === 'reasoning' ? block.text : '')
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
? summarizeText(messageText)
: thinkingText !== ''
? summarizeText(thinkingText)
: summarizeAssistantActivity(node.blocks),
...(messageText !== '' ? { outputDetail: messageText } : {}),
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
timeSeconds: messageDuration,
startedAt: recordedStart,
}
attachUsage(message, usage)
message.assistantMetrics = {
timingRecorded: node.timing !== undefined,
stepStartTime: node.timing?.stepStartTime ?? null,
firstTokenTime: node.timing?.firstTokenTime ?? null,
completedTime: streaming ? null : finiteTime(node.time),
usageProvided: usage !== undefined,
outputTokens: Number.isFinite(usage?.outputTokens) ? usage?.outputTokens ?? null : null,
}
out.push({ absTime: nodeAbs, cell: message })
for (const block of node.blocks) {
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
if (block.kind === 'reasoning') continue
if (block.kind === 'text') {
if (block.text === '' && streaming) continue
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: summarizeText(block.text),
timeSeconds: messageDuration,
}
if (!usageAttached) {
attachUsage(cell, usage)
usageAttached = usage !== undefined
}
out.push({ absTime: nodeAbs, cell })
continue
}
if (block.kind === 'tool-call') {
const result = results.get(block.callId)
const toolDuration = streaming || result === undefined
? null
: durationSeconds(result.time, result.callTime)
const callAbs = streaming
? null
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
? result.callTime
: nodeAbs)
out.push({
absTime: callAbs,
toolName: block.name,
// Text and reasoning belong to the one Assistant record emitted above.
if (block.kind !== 'tool-call') continue
const result = results.get(block.callId)
const toolDuration = streaming || result === undefined
? null
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
inputDetail: block.argsRaw,
callId: block.callId,
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
timeSeconds: toolDuration,
},
})
}
}
if (out.length === 0 && !streaming) {
// Reasoning-only / empty success still owns provider usage on the Message row.
const cell: TrajectoryCellProps = {
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
}
attachUsage(cell, usage)
out.push({ absTime: nodeAbs, cell })
...(result !== undefined
? {
outputDetail: detailResult(result),
outputBlocks: result.content.map(block => sourceBlock(block)),
result: summarizeResult(result),
isError: result.isError,
}
: {}),
timeSeconds: toolDuration,
startedAt: callAbs,
},
})
}
return out
}
function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
const tools = new Map<string, number>()
for (const block of blocks) {
if (block.kind !== 'tool-call') continue
tools.set(block.name, (tools.get(block.name) ?? 0) + 1)
}
if (tools.size > 0) {
return 'Tool call only'
}
return ''
}
function promptChangeLabel(change: RequestPromptChange): 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 }
case 'reasoning': return { type: 'thinking', content: block.text }
case 'tool-call': return {
type: 'tool-call',
content: block.argsRaw,
callId: block.callId,
toolName: block.name,
}
case 'other': return sourceBlock(block.block)
}
}
function sourceBlock(value: unknown): TrajectorySourceBlock {
if (typeof value !== 'object' || value === null) {
return { type: 'unknown', content: stringifySourceValue(value) }
}
const block = value as Record<string, unknown>
const type = typeof block.type === 'string' ? block.type : 'unknown'
if (typeof block.text === 'string') {
return { type: type === 'reasoning' ? 'thinking' : type, content: block.text }
}
const imageSrc = sourceImage(block)
const imageAlt = typeof block.alt === 'string' ? block.alt : undefined
return {
type,
content: imageSrc === undefined ? stringifySourceValue(value) : '',
...(imageSrc !== undefined ? { imageSrc } : {}),
...(imageAlt !== undefined ? { imageAlt } : {}),
}
}
function sourceImage(block: Record<string, unknown>): string | undefined {
if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined
for (const candidate of [block.url, block.image_url]) {
if (typeof candidate === 'string') return safeImageSource(candidate)
}
if (typeof block.data === 'string') {
const mediaType = [block.mimeType, block.mediaType, block.media_type]
.find((candidate): candidate is string => typeof candidate === 'string')
?? 'image/png'
return safeImageSource(
block.data.startsWith('data:')
? block.data
: `data:${mediaType};base64,${block.data}`,
)
}
if (typeof block.source !== 'object' || block.source === null) return undefined
const source = block.source as Record<string, unknown>
if (typeof source.url === 'string') return safeImageSource(source.url)
if (typeof source.data !== 'string') return undefined
const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png'
return safeImageSource(`data:${mediaType};base64,${source.data}`)
}
function safeImageSource(value: string): string | undefined {
if (value.startsWith('data:image/') || value.startsWith('blob:')) return value
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
return undefined
}
}
function stringifySourceValue(value: unknown): string {
const json = JSON.stringify(value, null, 2)
return json || String(value)
}
/**
* Turn that encloses a user/message: next assistant/steering turn, else the
* in-flight partial, else the turn after the last finalized assistant (or 1).
@@ -357,10 +721,37 @@ 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
}
/** Earliest raw turn represented by the selected trajectory branch. */
function firstVisibleTurn(
nodes: ConversationSnapshot['nodes'],
partial: ConversationSnapshot['partial'],
): number {
const turns = nodes.flatMap(node =>
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
? [node.turn]
: [],
)
if (partial !== null && partial.turn > 0) turns.push(partial.turn)
return turns.length === 0 ? 1 : Math.min(...turns)
}
/** Copy provider usage onto a Message cell when present. */
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
if (usage === undefined) return
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
if (usage.cacheReadTokens !== undefined) cell.cacheRead = usage.cacheReadTokens
if (usage.cacheWriteTokens !== undefined) cell.cacheWrite = usage.cacheWriteTokens
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
}
@@ -382,15 +773,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st
}
function collectCallIds(
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
turns: Map<number, TurnBucket>,
): Set<string> {
const ids = new Set<string>()
for (const entry of turns.values()) {
for (const laid of entry.message) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
for (const list of entry.steps.values()) {
for (const laid of list) {
for (const group of entry.groups) {
for (const laid of group.laid) {
if (laid.callId !== undefined) ids.add(laid.callId)
}
}
@@ -433,12 +821,27 @@ function expandSubCalls(
cell: {
index: ++index,
kind: 'subtool',
callId: sub.callId,
text: settled
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
: summarizeCall(sub.name, sub.argsRaw),
...(settled
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
: { inputDetail: sub.argsRaw }),
...(settled
? {
outputDetail: detailResult(sub),
outputBlocks: sub.content.map(block => sourceBlock(block)),
result: summarizeResult(sub),
isError: sub.isError,
}
: {}),
// PR3's start/settle pair carries per-sub-call wall time; a running
// (unsettled) or pre-pair log entry shows the em dash.
timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null,
startedAt: settled
? finiteTime(sub.callTime)
: finiteTime(sub.time),
},
})
}
@@ -448,8 +851,7 @@ function expandSubCalls(
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
if (args === '') return name
const clipped = args.length > 72 ? `${args.slice(0, 71)}` : args
return `${name} · ${clipped}`
return `${name} · ${args}`
}
function summarizeResult(node: ToolResultNode): string {
@@ -461,7 +863,40 @@ function summarizeResult(node: ToolResultNode): string {
return summarizeText(block.text)
}
}
return node.call?.name ?? node.callId
return 'No output'
}
function detailResult(node: ToolResultNode): string {
if (node.isError) {
return node.error === undefined
? 'error'
: `${node.error.name}: ${node.error.code}`
}
const text = node.content
.filter(block => block.type === 'text' && typeof block.text === 'string')
.map(block => block.type === 'text' ? block.text : '')
.join('\n')
if (text !== '') return text
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)
}
function detailContent(content: readonly { type: string; text?: string }[]): string {
return content
.filter(block => block.type === 'text' && typeof block.text === 'string')
.map(block => block.text ?? '')
.join('\n')
}
function 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 {

View File

@@ -1,147 +0,0 @@
/**
* Rough per-turn span derivation shared by the two placeholder views and the
* header stats bar. P-I ships no timing data, so a span's weight is its node
* count, not wall time (deviation ledger #3 — real spans land in P-III).
*/
import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */
export interface SubSpanLane {
callId: string
name: string
/** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */
durationMs: number | null
/**
* Timing provenance: `measured` = start/settle pair observed; `running` =
* start seen, settle pending; `unknown` = settle-only replay window (the
* start fell outside), so no duration claim is possible.
*/
timing: 'measured' | 'running' | 'unknown'
/** Start offset as a fraction of the parent turn's dispatch window [0, 1). */
offsetFraction: number
/** Width as a fraction of the window (running lanes extend to the window end). */
widthFraction: number
}
/** One turn's worth of activity, folded from the snapshot node window. */
export interface TurnSpan {
turn: number
/** Assistant step messages inside the turn. */
steps: number
/** Tool results inside the turn (running calls are not folded in P-I). */
calls: number
/** Total nodes attributed to the turn (span weight stand-in). */
nodes: number
}
/** Aggregate totals for the header stats bar. */
export interface SpanStats {
turns: number
steps: number
calls: number
}
/**
* Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn
* number; user/steering/context/tool nodes attach to the turn last seen in
* sequence order (turn 0 collects the pre-assistant prologue).
* @param nodes - snapshot nodes in surface order.
* @returns spans ordered by first appearance.
*/
export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] {
const spans = new Map<number, TurnSpan>()
let currentTurn = 0
const spanFor = (turn: number): TurnSpan => {
let span = spans.get(turn)
if (span === undefined) {
span = { turn, steps: 0, calls: 0, nodes: 0 }
spans.set(turn, span)
}
return span
}
for (const node of nodes) {
if (hasTurn(node)) currentTurn = node.turn
const span = spanFor(currentTurn)
span.nodes += 1
if (node.kind === 'assistant') span.steps += 1
if (node.kind === 'tool-result') span.calls += 1
}
return [...spans.values()]
}
/**
* Aggregate spans into the header totals.
* @param spans - deriveSpans product.
* @returns turn/step/call totals.
*/
export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats {
let steps = 0
let calls = 0
for (const span of spans) {
steps += span.steps
calls += span.calls
}
return { turns: spans.length, steps, calls }
}
function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } {
return node.kind === 'assistant' || node.kind === 'steering'
}
/**
* Fold the dispatch index into per-turn sub-span lanes with REAL timing: each
* lane's offset/width scale against its parent turn's dispatch window (first
* start → last settle). Running (unsettled) lanes extend to the window end
* with a null duration.
* @param nodes - snapshot nodes (locates each parent run_code call's turn).
* @param codeDispatches - the snapshot's dispatch index.
* @returns lanes keyed by turn, in start order.
*/
export function deriveSubSpans(
nodes: ConversationSnapshot['nodes'],
codeDispatches: ConversationSnapshot['codeDispatches'],
): ReadonlyMap<number, readonly SubSpanLane[]> {
const out = new Map<number, SubSpanLane[]>()
if (codeDispatches.size === 0) return out
const turnByCall = new Map<string, number>()
let currentTurn = 0
for (const node of nodes) {
if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn
if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn)
}
for (const [parent, subs] of codeDispatches) {
if (subs.length === 0) continue
const turn = turnByCall.get(parent) ?? currentTurn
// A settle-only entry (callTime null: its start fell outside the replay
// window) anchors the window by its settle time — a real observation —
// but must never masquerade as a measured zero-duration span.
const starts: number[] = []
const ends: number[] = []
for (const sub of subs) {
const settled = 'kind' in sub
const start = settled ? sub.callTime ?? sub.time : sub.time
starts.push(start)
ends.push(settled ? sub.time : start)
}
const windowStart = Math.min(...starts)
const windowEnd = Math.max(...ends, windowStart + 1)
const windowSpan = windowEnd - windowStart
const lanes: SubSpanLane[] = subs.map((sub, i) => {
const settled = 'kind' in sub
const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const
const start = starts[i] ?? windowStart
const end = settled ? sub.time : windowEnd
return {
callId: sub.callId,
name: settled ? sub.call?.name ?? sub.callId : sub.name,
durationMs: timing === 'measured' ? Math.max(0, end - start) : null,
timing,
offsetFraction: (start - windowStart) / windowSpan,
widthFraction: Math.max((end - start) / windowSpan, 0.02),
}
})
const existing = out.get(turn) ?? []
out.set(turn, [...existing, ...lanes])
}
return out
}

View File

@@ -0,0 +1,186 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
export type TrajectoryTimelineMode = 'sequence' | 'duration' | 'time' | 'actual'
/** Inclusive selection in the active timeline projection's domain. */
export interface TrajectoryTimeRange {
start: number
end: number
}
/** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
kind: TrajectoryCellKind
label: string
lane: number
}
/** One turn boundary in the active timeline domain. */
export interface TrajectoryTimelineTurnBoundary {
turn: number
time: number
}
/** Full-domain model used by the overview. */
export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
spans: readonly TrajectoryTimelineSpan[]
turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
}
/**
* Format a timeline duration with a compact unit.
* @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
}
function laneFor(kind: TrajectoryCellKind): number {
if (kind === 'tool' || kind === 'subtool') return 2
if (kind === 'message' || kind === 'compacted') return 1
return 0
}
function finite(value: number | null | undefined): value is number {
return value !== null && value !== undefined && Number.isFinite(value)
}
function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
if (!finite(cell.startedAt)) return null
const durationMs = finite(cell.timeSeconds)
? Math.max(0, cell.timeSeconds * 1_000)
: 0
return { start: cell.startedAt, end: cell.startedAt + durationMs }
}
/**
* Project every visible record into a stable three-lane timeline.
* @param turns - Unfiltered trajectory layout.
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
* @returns Timeline model, or `null` when no record is visible.
*/
export function deriveTrajectoryTimeline(
turns: readonly TrajectoryTurnModel[],
mode: TrajectoryTimelineMode = 'sequence',
): TrajectoryTimelineModel | null {
if (mode !== 'sequence') {
return deriveTimedTimeline(
turns,
mode === 'duration' || mode === 'actual',
mode === 'duration',
)
}
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
for (const turn of turns) {
const cells = turn.groups.flatMap(group =>
group.cells.filter(cell => cell.requestOnly !== true),
)
if (cells.length === 0) continue
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
start: spans.length + offset,
end: spans.length + offset + 1,
index: cell.index,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
})))
}
if (spans.length === 0) return null
return {
start: 0,
end: spans.length,
spans,
turnBoundaries,
}
}
function deriveTimedTimeline(
turns: readonly TrajectoryTurnModel[],
actualDuration: boolean,
removeUserIdle: boolean,
): TrajectoryTimelineModel | null {
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
let removedUserIdle = 0
let previousTurnEnd: number | null = null
for (const turn of turns) {
const rawSpans = turn.groups.flatMap(group =>
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return []
const range = cellRange(cell)
return range === null
? []
: [{
...range,
index: cell.index,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
}]
}),
)
if (rawSpans.length === 0) continue
const turnStart = Math.min(...rawSpans.map(span => span.start))
const turnEnd = Math.max(...rawSpans.map(span => span.end))
if (removeUserIdle && previousTurnEnd !== null) {
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
}
spans.push(...rawSpans.map(span => ({
...span,
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)
}
if (spans.length === 0) return null
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),
spans,
turnBoundaries,
}
}
/**
* Identify records active at any point inside an inclusive selected interval.
* @param turns - Unfiltered trajectory layout.
* @param range - Selected interval in the active projection.
* @param mode - Independent equal/recorded duration and compressed/complete time projection.
* @returns Record indexes inside the focus interval.
*/
export function trajectoryTimelineFocusIndexes(
turns: readonly TrajectoryTurnModel[],
range: TrajectoryTimeRange,
mode: TrajectoryTimelineMode = 'sequence',
): ReadonlySet<number> {
const model = deriveTrajectoryTimeline(turns, mode)
return new Set(
model?.spans
.filter(span => span.start <= range.end && span.end >= range.start)
.map(span => span.index),
)
}

View File

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

View File

@@ -1,82 +1,22 @@
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
* cell content width is capped on the turn body (max 880). */
/* Full-bleed, fixed-height host for the trajectory ledger. */
.root {
overflow-y: auto;
--dsh-trajectory-toolbar-height: 32px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
min-height: 0;
width: 100%;
box-sizing: border-box;
color: var(--dsw-alias-label-primary);
background: var(--dsw-specific-sidebar-fill);
background: var(--dsw-alias-bg-layer-1);
}
.empty {
padding: 16px;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
/* Waterfall placeholder rows (shared module). */
.row {
.ledger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 16px;
}
.turnTag {
flex: none;
width: 64px;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
}
.bar {
height: 12px;
border-radius: 4px;
background: var(--dsw-alias-bg-skeleton);
}
.barCalls {
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.meta {
color: var(--dsw-alias-label-caption);
font: var(--dsw-font-xs-13);
}
/* run_code sub-span lanes: one row per sub-dispatch under its turn row,
offset/width scaled to the dispatch window (real wall time). A running
lane pulses via reduced opacity until its settle arrives. */
.subRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.subTag {
flex: none;
width: 88px;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.barSub {
height: 8px;
background: var(--dsw-alias-state-business-primary);
}
.barSub[data-timing='running'] {
opacity: 0.45;
}
/* Settle-only replay entries: no measured span — hollow, not a solid bar. */
.barSub[data-timing='unknown'] {
background: transparent;
border: 1px dashed var(--dsw-alias-state-business-primary);
}

View File

@@ -16,8 +16,8 @@ export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin — it emits no cordis events
* and owns no mutable cross-plugin state; both view-slot registrations are
* plain effects whose disposal the slot ledger's own specs and this
* and owns no mutable cross-plugin state; its view-slot registration is a
* plain effect whose disposal the slot ledger's own specs and this
* package's behavior specs observe directly.
*/
const install: InvariantInstaller = () => {}

View File

@@ -16,11 +16,11 @@ afterEach(cleanup)
describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—')
expect(formatElapsedSeconds(235)).toBe('+235s')
expect(formatElapsedSeconds(235.0)).toBe('+235s')
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
expect(formatElapsedSeconds(0)).toBe('+0s')
expect(formatElapsedSeconds(235)).toBe('235 s')
expect(formatElapsedSeconds(235.0)).toBe('235 s')
expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
expect(formatElapsedSeconds(0)).toBe('0 s')
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
})
})
@@ -38,7 +38,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('#6')).toBeTruthy()
expect(screen.getByText('Tool')).toBeTruthy()
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
expect(screen.getByText('+5s')).toBeTruthy()
expect(screen.getByText('5 s')).toBeTruthy()
})
it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -57,11 +57,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('+235.2s')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s'))
})
it('selected marks the row for the brand-primary inset ring', () => {

View File

@@ -3,7 +3,7 @@
* Real tsdown artifact shape: lib/client.js hands off through
* window.__ModuleLoader__.load, resolves externals through the injected
* require, returns the export surface (apply + inject), and a mounted apply
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
* registers the view tab into a real SlotsService ring. Skips when dist/ is
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
*/
import { readFileSync } from 'node:fs'
@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])
const surface = handoff!.factory((spec) => {
if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
@@ -59,10 +60,10 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['slots', 'conversation'])
expect(surface.inject).toEqual(['slots', 'conversation', 'sessions'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
const { surface } = await loadArtifact()
const ctx = new Context()
const slots = new SlotsService(ctx)
@@ -71,13 +72,13 @@ describe('tsdown client artifact', () => {
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
// The plugin injects 'conversation' as an ordering edge (the declaring
// plugin provides it after declaring the ring); the bench declares the
// ring itself, so a stub satisfies the wait.
// The plugin injects 'conversation' as an ordering edge and 'sessions'
// for its per-session history callback; this bench supplies both.
ctx.provide('conversation', {})
ctx.provide('sessions', {})
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
await fiber.dispose()
expect(slots.entries('conversation.view')).toHaveLength(0)
})

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches,
trajectoryBranchContainsRequest,
} from '../src/client/context-branches.ts'
const checkpoint = {
kind: 'context',
seq: 100,
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
} as ConversationNode
const abandoned = {
kind: 'assistant',
seq: 20,
time: 20,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned' }],
} as ConversationNode
const current = {
kind: 'user',
seq: 110,
time: 110,
content: [{ type: 'text', text: 'rewound' }],
source: { kind: 'plugin', plugin: 'rewind' },
} as ConversationNode
function request(
purpose: RequestView['purpose'],
startSeq: number,
resultSeq?: number,
replacementSeq?: number,
): RequestView {
return {
purpose,
startSeq,
turn: 1,
step: purpose === 'assistant' ? 1 : 0,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
...(resultSeq === undefined ? {} : { resultSeq }),
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
const contexts: ConversationContext[] = [
{ id: 0, nodes: [checkpoint, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind',
originSeq: 110,
nodes: [checkpoint, current],
},
]
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 10, 20),
)).toBe(false)
expect(trajectoryBranchContainsRequest(
successor,
request('compaction', 90, 95, 100),
)).toBe(true)
expect(trajectoryBranchContainsRequest(
successor,
request('assistant', 111),
)).toBe(true)
})
})

View File

@@ -139,7 +139,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
@@ -161,7 +161,7 @@ describe('deriveTrajectoryLayout', () => {
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('keeps usage on the fallback Message row when assistant has no text block', () => {
it('keeps usage and a meaningful summary when assistant has no text block', () => {
const nodes = [
{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
@@ -172,7 +172,7 @@ describe('deriveTrajectoryLayout', () => {
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '', input: 11, output: 22, think: 3,
text: '', input: 11, output: 22, think: 3,
})
})
@@ -204,6 +204,23 @@ describe('deriveTrajectoryLayout', () => {
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
})
it('uses the recorded step start for assistant duration when timing exists', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
{
kind: 'assistant', seq: 2, time: 4_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'done' }],
timing: { stepStartTime: 3_000, firstTokenTime: 3_500, completedTime: 4_000 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message).toMatchObject({ startedAt: 3_000, timeSeconds: 1 })
})
})
describe('run_code sub-dispatch cells', () => {
@@ -235,11 +252,12 @@ describe('run_code sub-dispatch cells', () => {
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('Tool call only')
// Sequential indexes across the interleave; durations from the pair times.
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4])
expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
expect(cells[3]).toMatchObject({ timeSeconds: 0.5 })
})
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {

View File

@@ -0,0 +1,190 @@
// @vitest-environment jsdom
/** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
afterEach(cleanup)
const TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
description: '1.5s bash×2',
cells: [
{
index: 1,
kind: 'message',
text: 'Checking files',
outputDetail: 'Checking files',
input: 10,
output: 20,
think: 5,
timeSeconds: 1.5,
assistantMetrics: {
timingRecorded: true,
stepStartTime: 1_000,
firstTokenTime: 1_500,
completedTime: 2_500,
usageProvided: true,
outputTokens: 20,
},
},
{
index: 2,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
timeSeconds: null,
},
{
index: 3,
kind: 'tool',
text: 'bash · {"command":"false"}',
inputDetail: '{"command":"false"}',
outputDetail: 'ToolError: non_zero_exit',
result: 'non_zero_exit',
isError: true,
timeSeconds: 0.2,
},
],
}],
}]
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
expect(screen.getByText('500 ms')).toBeTruthy()
expect(screen.getByText('1.00 s')).toBeTruthy()
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
expect(screen.getByText('Tokens')).toBeTruthy()
expect(screen.getByText('20 tok')).toBeTruthy()
expect(screen.getByText('Reasoning')).toBeTruthy()
expect(screen.getByText('5 tok')).toBeTruthy()
expect(screen.getByText('Content')).toBeTruthy()
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [
'<background-task-complete id="trajectory-ui-watch">',
'Command: pnpm test',
'Exit code: 0',
'</background-task-complete>',
].join('\n')
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Message',
cells: [{
index: 1,
kind: 'context',
text: '',
inputDetail: html,
timeSeconds: 0,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(screen.getByText(
'<background-task-complete id="trajectory-ui-watch"> Command: pnpm test Exit code: 0 </background-task-complete>',
)).toBeTruthy()
})
it('clears the selected row when ledger whitespace is clicked', () => {
const onClearSelection = vi.fn()
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
onClearSelection={onClearSelection}
/>,
)
const row = screen.getByRole('row', { name: /ASSISTANT/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(row.getAttribute('aria-selected')).toBe('false')
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
expect(view.container.querySelector('tr[data-kind="tool"][data-error="true"]')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"pwd"\}/ }))
expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
})
it('renders a single-text JSON tool result as a JSON tree', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'read {"path":"result.json"}',
outputDetail: '{"value":1,"nested":{"ok":true}}',
outputBlocks: [{
type: 'text',
content: '{"value":1,"nested":{"ok":true}}',
}],
timeSeconds: 0.1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy()
expect(screen.getByText('value:')).toBeTruthy()
})
it('keeps the first row and a compact summary when a turn is collapsed', () => {
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
collapsedTurns={new Set([1])}
/>,
)
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
})
})

View File

@@ -1,31 +1,34 @@
// @vitest-environment jsdom
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real SlotsService view ring, tabs
* registers Trajectory into a real SlotsService view ring, tabs
* switch inside ConversationRoot (renderSlot share driven by the same tab
* projection apply uses) without collapsing chat, trajectory renders the
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
* fiber disposal removes both tabs. Span derivation edge cases ride along.
* event ledger with its timing overview, and fiber disposal removes the tab.
* Timeline projection and inclusive focus edge cases ride along.
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type FC, type ReactNode } from 'react'
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection,
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
afterEach(cleanup)
@@ -40,17 +43,56 @@ beforeEach(() => {
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [],
timing: { stepStartTime: 1_800, firstTokenTime: 1_900, completedTime: 2_000 },
},
{
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: 2_200,
content: [], isError: false, callView: null, resultView: null,
},
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
{
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [],
timing: { stepStartTime: 3_500, firstTokenTime: 3_700, completedTime: 4_000 },
},
] as unknown as ConversationSnapshot['nodes']
function historySnapshot(
nodes: ConversationSnapshot['nodes'],
inspection: Partial<SessionHistoryInspection> = {},
): SessionHistorySnapshot {
return {
state: 'ready',
error: null,
hasMore: false,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [],
partial: null,
runningCalls: [],
codeDispatches: new Map(),
...inspection,
},
}
}
function standaloneHistory(
snapshot: SessionHistorySnapshot,
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadAllHistory: () => Promise.resolve(),
}
}
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -82,9 +124,17 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
}
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
@@ -96,9 +146,10 @@ async function bench() {
// 'conversation' inject is an ordering edge; the bench declares the ring
// itself, so a stub satisfies the wait.
ctx.provide('conversation', {})
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber }
return { ctx, slots, fiber, loadAllHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -111,6 +162,8 @@ function tabsOf(slots: SlotsService): ViewTab[] {
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
@@ -122,8 +175,21 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
if (entry === undefined) return null
const View = entry.component as FC<ConvViewProps>
const injectEntry = entry.inject as ((sessionId: SessionId) => object) | undefined
const injected = injectEntry === undefined
? {}
: injectEntry(SID)
const injectedProps = 'hooks' in injected
? {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history,
),
}
: injected
return (
<View
{...injectedProps}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
@@ -154,16 +220,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
}
describe('plugin registration', () => {
it('registers trajectory and waterfall after chat on the ring', async () => {
it('registers trajectory after chat on the ring', async () => {
const b = await bench()
expect(tabsOf(b.slots)).toEqual([
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'waterfall', label: 'Waterfall' },
])
})
it('fiber disposal removes both tabs and leaves chat standing', async () => {
it('fiber disposal removes the tab and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
@@ -171,80 +236,275 @@ describe('plugin registration', () => {
})
describe('tab switching in ConversationRoot', () => {
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
it('renders two tabs, defaults to chat, and switches to the trajectory ledger', async () => {
const b = await bench()
mount(b.slots)
const view = mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.queryByText(/turns ·/)).toBeNull()
expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy()
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2)
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
})
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
it('opens a local record inspector and switches payload tabs without opening chat details', async () => {
const b = await bench()
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
expect(screen.getByTitle('2 nodes')).toBeTruthy()
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(screen.getByTestId('chat-body')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('row', { name: /TOOL/ }), { key: 'Enter' })
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
expect(screen.getByText('Turn 1 · Step 1')).toBeTruthy()
expect(screen.getByText('Completed')).toBeTruthy()
expect(screen.getByRole('tab', { name: 'Result' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close details' }))
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
})
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
const b = await bench()
mount(b.slots, [])
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 1 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
fireEvent.contextMenu(plot)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([]))
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
expect(screen.queryByText(/turns ·/)).toBeNull()
})
})
describe('span derivation', () => {
it('attributes prologue to turn 0 and follows steering turn tags', () => {
const nodes = [
{ kind: 'user', seq: 1 },
{ kind: 'steering', seq: 2, turn: 5 },
{ kind: 'user', seq: 3 },
] as unknown as ConversationSnapshot['nodes']
const spans = deriveSpans(nodes)
expect(spans).toEqual([
{ turn: 0, steps: 0, calls: 0, nodes: 1 },
{ turn: 5, steps: 0, calls: 0, nodes: 2 },
])
expect(deriveSpanStats(spans)).toEqual({ turns: 2, steps: 0, calls: 0 })
describe('timeline projection', () => {
const turns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'assistant', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'bash', startedAt: 2_000, timeSeconds: 1 },
{ index: 3, kind: 'user', text: 'unknown', timeSeconds: 0 },
],
}],
}] satisfies readonly TrajectoryTurnModel[]
it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
start: 0,
end: 3,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
],
turnBoundaries: [{ turn: 1, time: 0 }],
})
})
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([])
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
start: 0,
end: 3,
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement(
TrajectoryView,
{
...standaloneProps([]),
...standaloneHistory(historySnapshot([])),
},
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.queryByRole('row')).toBeNull()
})
})
describe('WaterfallView standalone branches', () => {
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
describe('TrajectoryView branches', () => {
it('renders only the selected rewind branch while retaining session-global requests', () => {
const retained = {
kind: 'user',
seq: 1,
time: 1_000,
content: [{ type: 'text', text: 'retained user' }],
source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const abandoned = {
kind: 'assistant',
seq: 3,
time: 3_000,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const request = (startSeq: number, turn: number): RequestView => ({
purpose: 'assistant',
startSeq,
turn,
step: 1,
startedAt: startSeq * 1_000,
completedAt: startSeq * 1_000 + 100,
status: 'complete',
})
const store = createSnapshotStore(historySnapshot(
[retained, abandoned, current],
{
eventNodes: [retained, abandoned, current],
contexts: [
{ id: 0, nodes: [retained, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind' as const,
originSeq: 4,
nodes: [retained, current],
},
],
requests: [request(2, 1), request(4, 2)],
callSchemas: new Map(),
},
))
const view = render(
<TrajectoryView
{...standaloneProps([])}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,
)
expect(screen.queryByText('abandoned response')).toBeNull()
expect(screen.getByText('current response')).toBeTruthy()
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'stop the task' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedAssistant = {
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'partial response retained' }],
interrupted: true,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedTool = {
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
content: [], isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null, resultView: null,
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot(
[retained],
{
eventNodes: [retained],
contexts: [{ id: 0, nodes: [retained] }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [interruptedAssistant, interruptedTool],
},
))
render(
<TrajectoryView
{...standaloneProps([])}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
})
@@ -253,117 +513,3 @@ describe('node half', () => {
expect(() => { nodeApply() }).not.toThrow()
})
})
describe('deriveSubSpans (waterfall lanes)', () => {
const dispatchNodes = [
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
{
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
it('scales settled lanes into the dispatch window with real durations', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const turn3 = lanes.get(3)
expect(turn3).toHaveLength(2)
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
})
it('a running lane extends to the window end with a null duration', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
// Extends from its start to the window end.
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
})
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
})
it('waterfall renders sub-span lanes under the owning turn row', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never,
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const lane = view.container.querySelector('[data-subspan]')
expect(lane).not.toBeNull()
expect(lane!.textContent).toContain('bash')
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
})
it('waterfall labels a settle-only lane as duration unknown', () => {
const codeDispatches = new Map([['p1', [
{
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{}' }, callTime: null,
content: [], isError: false, callView: null, resultView: null,
},
]]]) as unknown as ConversationSnapshot['codeDispatches']
const store = createSnapshotStore({
nodes: dispatchNodes, partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
})
const props = {
sessionId: SID,
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useProjection: (() => undefined) as never,
} as unknown as ConvViewProps
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
const bar = view.container.querySelector('[data-timing="unknown"]')
expect(bar).not.toBeNull()
expect(bar!.getAttribute('title')).toContain('duration unknown')
})
})