Merge branch 'claude/web-llm-pi-ai-config-385e24' into claude/pi-ai-model-discovery

# Conflicts:
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/event-producer-consumer.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-06 10:50:20 +08:00
822 changed files with 18341 additions and 14753 deletions

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
@@ -34,6 +34,7 @@ export {
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'

View File

@@ -339,7 +339,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / steering / context. */
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record<string, unknown>[] = []
let time = Date.now() - 3_600_000
@@ -359,7 +359,7 @@ function buildAlphaLog(): SessionEvent[] {
return seq
}
for (let turn = 0; turn < 60; turn++) {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'turn/start', data: { turn } })
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`)),
@@ -393,9 +393,6 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
push({ type: 'step/end', data: { turn, step: 0 } })
}
if (turn % 13 === 6) {
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}fixture steering 消息。`)) } })
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
@@ -403,7 +400,7 @@ function buildAlphaLog(): SessionEvent[] {
// stays presenter-less as the unknown fallback.
const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
const callId = `fx-call-${turn}`
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'turn/start', data: { turn } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}${name} 样本。`)) })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
@@ -440,7 +437,7 @@ function buildAlphaLog(): SessionEvent[] {
+ 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
push({ type: 'turn/start', data: { turn } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}run_code 样本。`)) })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
@@ -685,7 +682,7 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
* Fixture parallel of the plan unit's double-event fold: `command/run`
* records named `plan` set the wanted target (`off` → false, else true);
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
* boundary (the fixture's agent/step parallel).
* boundary (the fixture's step/start parallel).
*/
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
let active = false
@@ -901,13 +898,9 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Goal fold: a round-zero goal-sourced user message advances the goal unit.
if (type === 'user/message') {
const source = (event as unknown as { data?: { source?: { kind?: string; round?: number } } }).data?.source
if (source?.kind === 'goal' && source.round === 0) {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
return []
// The goal domain's own durable change advances its projection.
if (type === 'goal/change') {
return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }]
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
@@ -961,7 +954,7 @@ function pageOf(
const event = log[i]
/* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
if (event === undefined) break
if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
if (event.type === 'user/message' || event.type === 'assistant/message') messages++
if (event.type === 'turn/start' && messages >= maxMessages) {
start = i
break
@@ -990,11 +983,11 @@ function searchBlockText(block: ContentBlock): string[] {
}
}
/** One current-surface user/assistant/steering document, if searchable. */
/** One current-surface user/assistant document, if searchable. */
function searchEventText(event: SessionEvent): string {
const content = event.type === 'user/message'
? event.data.content
: event.type === 'assistant/message' || event.type === 'steering/message'
: event.type === 'assistant/message'
? event.data.message.content
: undefined
if (content === undefined) return ''
@@ -1140,7 +1133,7 @@ interface FxGoalProjection {
updatedAt: number
}
/** One durable goal change riding a round-zero goal-sourced user message. */
/** One durable goal change. */
type FxGoalChange =
| { kind: 'goal/change'; version: 1; operation: 'clear'; cleared: { id: string; revision: number }; clearedAt: number }
| {
@@ -1161,14 +1154,10 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i] as unknown as {
type: string
data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } }
data?: FxGoalChange
} | undefined
if (event === undefined || event.type !== 'user/message') continue
const source = event.data?.source
if (source?.kind !== 'goal' || source.round !== 0) continue
const change = source.change
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (change === undefined || change.kind !== 'goal/change') continue
if (event === undefined || event.type !== 'goal/change' || event.data === undefined) continue
const change = event.data
if (change.operation === 'clear') return null
return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
}
@@ -1419,20 +1408,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
for (const frame of projectionFramesOf(id, log, event)) emitMux(frame)
}
/** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */
/** Append one durable goal/change (host GoalService parallel). */
const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => {
const ref = change.operation === 'clear' ? change.cleared : change.goal
const payload = change.operation === 'clear'
? { cleared: change.cleared, clearedAt: change.clearedAt }
: { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt }
const log = logOf(id)
append(id, {
type: 'user/message', surfaceOp: 'append',
data: userMessage(
text(`<goal_state>${JSON.stringify(payload)}</goal_state>`),
{ kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource,
),
type: 'goal/change',
data: change,
})
return backscanGoal(logOf(id)) as FxGoalProjection
return backscanGoal(log) as FxGoalProjection
}
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
@@ -1583,23 +1566,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
nextTurn.set(sessionId, turn + 1)
retryScenarios.set(sessionId, { turn, stepStarted: true })
setRunning(sessionId, true)
append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
append(sessionId, { type: 'turn/start', data: { turn } })
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
},
/** Record one retry decision, then open the next retry turn. */
/** Record one retry decision; the next attempt remains in the same step. */
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
if (!scenario.stepStarted) {
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `${String(retry)} 次应撤回的回复` } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
scenario.stepStarted = true
}
const failure = { code: 'TRANSPORT', message: '连接被重置' }
@@ -1611,14 +1591,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
retry, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, {
type: 'turn/end',
data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
})
const next = nextTurn.get(sessionId) ?? scenario.turn + 1
nextTurn.set(sessionId, next + 1)
append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
scenario.turn = next
scenario.stepStarted = false
},
/** Record one retry decision, then cancel its source turn before the retry starts. */
@@ -1635,17 +1607,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
retry: 1, maxRetries: 2, delayMs, failure,
},
})
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted', reason: { kind: 'user' } },
} })
retryScenarios.delete(sessionId)
setRunning(sessionId, false)
},
/** Finish the timing-hook retry with a finalized response in the open retry turn. */
/** Finish the timing-hook retry with a finalized response in the open step. */
completeModelRetry(id: string): void {
const sessionId = sid(id)
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
retryScenarios.delete(sessionId)
append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'assistant/chunk', data: {
turn: scenario.turn,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'text' },
} })
append(sessionId, {
type: 'assistant/message',
surfaceOp: 'append',
@@ -1944,17 +1922,15 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (mode === 'steer' && replays.has(id)) {
// Steering: insert a steering message into the current turn; the replay continues.
/* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
const turn = (nextTurn.get(id) ?? 1) - 1
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(content) } })
// Steering: the durable user/message lands inside the current turn; the replay continues.
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
nextTurn.set(id, turn + 1)
setRunning(id, true)
append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
// Boundary flush parallel (the host's agent/step seam): an outstanding
append(id, { type: 'turn/start', data: { turn } })
// Boundary flush parallel (the host's step/start observer): an outstanding
// /plan selection commits as plan/mode inside the opened turn.
const plan = foldPlan(logOf(id))
if (plan.wanted !== null && plan.wanted !== plan.active) {

View File

@@ -18,7 +18,7 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,

View File

@@ -311,7 +311,7 @@ describe('createFixtureApi', () => {
expect(idleCancel.result).toMatchObject({ ok: true })
})
it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
it('steer during a replay lands a user/message inside the current turn and the replay continues', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
@@ -324,7 +324,7 @@ describe('createFixtureApi', () => {
await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types).toContain('steering/message')
expect(JSON.stringify(frames)).toContain('插话')
expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
})
@@ -372,7 +372,7 @@ describe('createFixtureApi', () => {
}))
const frames = await framesPromise
const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert
})
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
@@ -1008,6 +1008,21 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
// complete → complete is an invalid transition.
expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } })
const goalHistory = await client.sessions.history({ sessionId: id })
if (!goalHistory.result.ok) throw new Error('goal history failed')
const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as {
type: string
data: {
operation?: string
source?: { kind?: string; round?: number }
}
})
const goalChanges = goalEvents.filter(event => event.type === 'goal/change')
expect(goalChanges.map(event => event.data.operation))
.toEqual(['create', 'edit', 'pause', 'resume', 'complete', 'clear'])
expect(goalEvents.some(event => event.type === 'user/message'
&& event.data.source?.kind === 'goal' && event.data.source.round === 0)).toBe(false)
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

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: 542d6e5bacf7842533339f4cbbeeddd35df8be79
README.zh.md: 8a0f10952eccad4df546c122c0365b027e94d7c0
README.md: b390c2830a47ed380e01bc7ec763b4bd8d8459e8
README.zh.md: 0aaad0b7620394f151b6a757f924d22d4f2140ab

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 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, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
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, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. 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, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. 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`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Workspace and Session lists
@@ -24,7 +24,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its `InboxItemId`, stable `MessageId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection, while an accepted live `steering/message` event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same `MessageId`. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`.
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## The human transcript
@@ -56,10 +56,6 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 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`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 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`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Workspace 与 Session 列表
@@ -24,7 +24,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照,携带 queued 与待处理 steering中途引导单次入队项及其已解析 placement。每行携带其 `InboxItemId`、稳定的 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;已接纳的实时 `steering/message` 事件则只退役第一个匹配的当前 steering 单次入队项,让持久节点能在下一份 Host 快照之前接管,而历史回放绝不会消费后来复用同一 `MessageId` 的单次入队项。重连缓冲只保留最新快照,普通持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑移除和严格 steering 操作,不进行乐观更新;认领与窗口关闭竞态分别会返回 `queue-item-not-found``steer-unavailable`
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`
## 面向人的 transcript文本记录
@@ -56,10 +56,6 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flightHost 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。

View File

@@ -9,6 +9,8 @@ export interface SessionHistorySnapshot {
state: 'cold' | 'loading' | 'ready' | 'error'
error: RpcError | null
hasMore: boolean
/** Absolute sequence of the first loaded raw event, or zero for an empty window. */
baseSeq: number
inspection: SessionHistoryInspection
}
@@ -17,11 +19,17 @@ 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.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadAll(signal?: AbortSignal): Promise<void>
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** Runtime service resolving independent history sources. */

View File

@@ -9,7 +9,7 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type {
InboxItemId, QueueAction, RpcResult, SessionId,
MessageId, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -44,7 +44,7 @@ export interface ISession {
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn. Pending queued work remains and resumes in FIFO
* order after the Host reaches cancellation quiescence.

View File

@@ -48,7 +48,7 @@ export type {
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,

View File

@@ -49,18 +49,10 @@ 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'
@@ -84,27 +76,63 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']):
}
}
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
function foldContexts(
events: readonly SessionEvent[],
): readonly FoldedContext[] {
const replay: SessionEvent[] = []
const originalSeqs: number[] = []
const rebasedSeqByOriginal = new Map<number, number>()
const surface = new SurfaceManager(replay)
const contexts: FoldedContext[] = []
let generation = 0
let originSeq: number | undefined
const originalNodes = () => surface.nodes.map((seq) => {
const original = originalSeqs[seq]
if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`)
return original
})
for (const event of events) {
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp !== 'append') {
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
generation++
originSeq = event.seq
}
replay.push(event)
const rebasedSeq = replay.length
const {
sourceEventSeqs: rawSources,
...eventWithoutSources
} = event as SessionEvent & { sourceEventSeqs?: readonly number[] }
const mappedSourceEventSeqs = rawSources?.flatMap((seq) => {
const rebased = rebasedSeqByOriginal.get(seq)
return rebased === undefined ? [] : [rebased]
})
const sourceEventSeqs = mappedSourceEventSeqs?.length === 0
? undefined
: mappedSourceEventSeqs
const surfaceOp = event.surfaceOp === 'append'
? event.surfaceOp
: {
...event.surfaceOp,
start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start,
end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end,
}
originalSeqs.push(event.seq)
rebasedSeqByOriginal.set(event.seq, rebasedSeq)
replay.push({
...eventWithoutSources,
seq: rebasedSeq,
surfaceOp,
...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
} as SessionEvent)
}
contexts.push({
generation,
nodes: [...surface.nodes],
nodes: originalNodes(),
...(originSeq === undefined ? {} : { originSeq }),
})
return contexts
@@ -144,12 +172,6 @@ function materializeNode(
...(requestConfig === undefined ? {} : { requestConfig }),
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
}
case 'steering/message':
return {
kind: 'steering', messageId: event.data.message.id,
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)
@@ -332,10 +354,7 @@ export function projectConversationHistory(
): 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 eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
const resultViews = new Map<number, ToolResultView>()
const assistantSteps = new Map<string, AssistantStepMetadata>()
@@ -405,7 +424,7 @@ export function projectConversationHistory(
const materialize = (seq: number): ConversationNode | undefined => {
const cached = nodeCache.get(seq)
if (cached !== undefined) return cached
const event = padded[seq]
const event = eventsBySeq.get(seq)
if (event === undefined || !isSurfaceEligibleType(event.type)) return
const node = materializeNode(
event,
@@ -431,7 +450,7 @@ export function projectConversationHistory(
}]
} else {
try {
contexts = foldContexts(padded).map((context): ConversationContext => {
contexts = foldContexts(events).map((context): ConversationContext => {
const nodes = context.nodes.flatMap((seq) => {
const node = materialize(seq)
return node === undefined ? [] : [node]
@@ -444,7 +463,7 @@ export function projectConversationHistory(
nodes,
}
}
const originEvent = padded[context.originSeq]
const originEvent = eventsBySeq.get(context.originSeq)
return {
id: context.generation,
parentId: context.generation - 1,

View File

@@ -6,7 +6,9 @@ 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 {
compactHistoryInspectionEntries, createHistoryInspection,
} from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts'
@@ -18,7 +20,8 @@ function isAborted(signal: AbortSignal | undefined): boolean {
/** Independent raw-history owner used only by inspection consumers. */
export class SessionHistorySource implements SessionHistoryFace {
private entries: readonly HistoryEntry[] = []
private entries: HistoryEntry[] = []
private inspectionEntries: readonly HistoryEntry[] = []
private baseSeq = 0
private hasMore = false
private state: SessionHistorySnapshot['state'] = 'cold'
@@ -36,7 +39,6 @@ export class SessionHistorySource implements SessionHistoryFace {
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
@@ -73,37 +75,29 @@ export class SessionHistorySource implements SessionHistoryFace {
}
/**
* Load the tail and exhaust all available older pages.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
* @returns When the tail is ready or loading fails.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) 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> {
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
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
}
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/**
@@ -144,12 +138,13 @@ export class SessionHistorySource implements SessionHistoryFace {
this.liveBuffer = []
this.subscribedLastSeq = null
this.entries = []
this.inspectionEntries = []
this.baseSeq = 0
this.hasMore = false
this.state = 'cold'
this.error = null
this.publishDirtyNow()
void this.loadForConsumers()
void this.open()
}
/** Stop future refresh work after the host removes the session. */
@@ -161,7 +156,6 @@ export class SessionHistorySource implements SessionHistoryFace {
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
@@ -234,7 +228,7 @@ export class SessionHistorySource implements SessionHistoryFace {
}
}
private loadOlder(): Promise<void> {
private loadOlderPage(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
@@ -260,6 +254,7 @@ export class SessionHistorySource implements SessionHistoryFace {
return
}
this.entries = [...older, ...this.entries]
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
} catch (error) {
@@ -291,6 +286,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...prefix, ...tail]
}
this.baseSeq = this.entries[0]?.event.seq ?? 0
this.inspectionEntries = compactHistoryInspectionEntries([...this.entries])
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
@@ -324,7 +320,11 @@ export class SessionHistorySource implements SessionHistoryFace {
private appendLive(entry: HistoryEntry): void {
const tailSeq = this.tailSeq()
if (tailSeq !== null && entry.event.seq <= tailSeq) return
this.entries = [...this.entries, entry]
this.entries.push(entry)
this.inspectionEntries = [...this.inspectionEntries, entry]
if (entry.event.type === 'assistant/message') {
this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries)
}
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
@@ -336,11 +336,10 @@ export class SessionHistorySource implements SessionHistoryFace {
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
this.inspectionCache = { entries: this.inspectionEntries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
@@ -356,7 +355,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
entries: this.inspectionEntries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
@@ -382,7 +381,6 @@ export class SessionHistorySource implements SessionHistoryFace {
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
@@ -415,14 +413,15 @@ export class SessionHistorySource implements SessionHistoryFace {
state: this.state,
error: this.error,
hasMore: this.hasMore,
baseSeq: this.baseSeq,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
if (this.inspectionCache?.entries !== this.inspectionEntries) {
const entries = this.inspectionEntries
this.inspectionCache = {
entries,
value: createHistoryInspection(() => entries),

View File

@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -102,19 +102,6 @@ export interface AssistantMessageNode {
interrupted?: true
}
/** A steering message injected mid-turn. */
export interface SteeringMessageNode {
kind: 'steering'
/** Stable identity shared with its pre-admission inbox occurrence. */
messageId: MessageId
seq: number
/** Unix epoch ms from the source session event. */
time: number
turn: number
content: readonly ContentBlock[]
source: unknown
}
/** A context/system injection surfaced in the flow. */
export interface ContextMessageNode {
kind: 'context'
@@ -236,7 +223,6 @@ export interface CommandNode {
export type ConversationNode =
| UserMessageNode
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
@@ -276,11 +262,11 @@ export interface RunningToolCall {
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage {
readonly id: InboxItemId
readonly id: MessageId
/** Stable message identity used for transient-to-durable steering handoff. */
readonly messageId: MessageId
/** Agent-resolved placement; only queued rows accept queue mutations. */
readonly placement: 'queued' | 'steering'
readonly placement: 'queued' | 'steering' | 'context'
/** Complete content used to render pending steering before it becomes durable. */
readonly content: readonly ContentBlock[]
readonly preview: string

View File

@@ -1,10 +1,13 @@
/**
* Convert a durable failure into copy that is safe to expose in the GUI.
* @param failure - Structured failure preserved by the session event.
* @param failure - Failure value preserved by the session event.
* @returns Display-safe copy for client projections.
*/
export function displayFailureMessage(failure: { code?: string; message: string }): string {
export function displayFailureMessage(failure: unknown): string {
if (failure === null || typeof failure !== 'object') return String(failure)
const record = failure as { code?: unknown; message?: unknown }
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
if (record.code === 'AUTH') return 'API key is invalid'
return typeof record.message === 'string' ? record.message : JSON.stringify(failure)
}

View File

@@ -7,6 +7,24 @@ import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
import { inspectRequests, type RequestView } from './request-inspection.ts'
function assistantStepKey(turn: number, step: number): string {
return `${turn}\u0000${step}`
}
function isFirstTokenCandidate(entry: HistoryEntry): boolean {
const event = entry.event
if (event.type !== 'assistant/chunk') return false
switch (event.data.chunk.type) {
case 'text-delta':
case 'reasoning-delta':
return event.data.chunk.text !== ''
case 'tool-call-delta':
return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined
default:
return false
}
}
/** Lazily derived inspection data for one immutable session-history window. */
export interface SessionHistoryInspection {
eventNodes: readonly ConversationNode[]
@@ -19,6 +37,47 @@ export interface SessionHistoryInspection {
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
* Remove completed-step token payloads that no inspection projection reads.
* The first visible token preserves timing, usage chunks preserve accounting,
* and unfinished steps retain every chunk for live or interrupted content.
* @param entries - Contiguous raw history entries in sequence order.
* @returns A projection-equivalent, usually much smaller entry ledger.
*/
export function compactHistoryInspectionEntries(
entries: readonly HistoryEntry[],
): readonly HistoryEntry[] {
const completedSteps = new Set<string>()
for (const { event } of entries) {
if (event.type === 'assistant/message') {
completedSteps.add(assistantStepKey(event.data.turn, event.data.step))
}
}
const firstTokenSteps = new Set<string>()
const compacted: HistoryEntry[] = []
let changed = false
for (const entry of entries) {
const event = entry.event
if (event.type !== 'assistant/chunk') {
compacted.push(entry)
continue
}
const key = assistantStepKey(event.data.turn, event.data.step)
if (!completedSteps.has(key) || event.data.chunk.type === 'usage') {
compacted.push(entry)
continue
}
if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) {
firstTokenSteps.add(key)
compacted.push(entry)
} else {
changed = true
}
}
return changed ? compacted : entries
}
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots

View File

@@ -94,7 +94,8 @@ export interface RequestInspectionSnapshot {
/**
* Derive the request-centric read model from one immutable history window.
* Compaction participates as a request purpose rather than a parallel
* top-level collection.
* top-level collection. A leading resume/change header exposes its prompt but
* cannot project a change until the preceding header enters the window.
* @param entries - Contiguous raw session history.
* @returns Requests and call-time schemas derived from that history.
*/
@@ -218,6 +219,7 @@ function promptChange(
prompt: ConversationPromptSnapshot,
event: SessionEvent<'request/header'>,
): RequestPromptChange | undefined {
if (previous === undefined && event.data.reason !== 'initial') return
const systemChanged = previous !== undefined && previous.system !== prompt.system
const toolsChanged = previous !== undefined
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
@@ -240,6 +242,7 @@ function promptChange(
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
const requests: RequestView[] = []
const ordinaryByStep = new Map<string, number>()
const lastStepByTurn = new Map<number, string>()
let activeStep: string | undefined
let activePrompt: ConversationPromptSnapshot | undefined
let activeCompaction: number | undefined
@@ -266,6 +269,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
const { turn, step } = sourceEvent.data
const key = requestKey(turn, step)
ordinaryByStep.set(key, requests.length)
lastStepByTurn.set(turn, key)
requests.push({
purpose: 'assistant',
startSeq: sourceEvent.seq,
@@ -358,12 +362,15 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
})
continue
}
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
const reason = sourceEvent.data.reason
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
})
if (sourceEvent.type === 'turn/end') {
const lastStep = lastStepByTurn.get(sourceEvent.data.turn)
if (sourceEvent.data.reason.kind === 'error') {
updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), {
status: 'error',
error: displayFailureMessage(sourceEvent.data.reason.error),
})
}
lastStepByTurn.delete(sourceEvent.data.turn)
continue
}

View File

@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
@@ -98,6 +98,8 @@ export class Session implements SessionFace {
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Last entered step per turn, folded from step/start for terminal error placement. */
private lastStepByTurn = new Map<number, number>()
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events and rebuilt with partial/openCalls; the transcript is
* seq-monotonic, so a plain seq merge preserves event order. */
@@ -271,7 +273,7 @@ export class Session implements SessionFace {
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {
return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
} catch (error) {
@@ -664,11 +666,12 @@ export class Session implements SessionFace {
this.applyEventSideEffects(event, view)
}
/** Retire the first matching live steering occurrence when its durable event takes over. */
/** Retire the first matching live steering occurrence when its durable message takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'steering/message') return
if (event.type !== 'user/message') return
const message = event.data
const index = this.queued.findIndex(item =>
item.placement === 'steering' && item.messageId === event.data.message.id)
item.placement === 'steering' && item.messageId === message.id)
if (index === -1) return
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
this.queueRev++
@@ -803,14 +806,17 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
case 'turn/start': {
case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0)
this.turnTimings.set(event.data.turn, { startTime: event.time })
this.turnTimingsRev++
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
return
}
case 'step/start':
this.lastStepByTurn.set(event.data.turn, event.data.step)
return
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
this.settleScheduledRetry('started', turn)
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
@@ -837,6 +843,7 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0
const timing = this.turnTimings.get(event.data.turn)
if (timing !== undefined) {
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
@@ -844,25 +851,26 @@ export class Session implements SessionFace {
}
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
if (event.data.reason.kind === 'aborted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
const failure = event.data.reason.error
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: event.data.reason.step,
step: lastStep,
message: displayFailureMessage(failure),
...(failure.code === undefined ? {} : { code: failure.code }),
code: failure.code,
})
this.derivedRev++
}
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -897,6 +905,7 @@ export class Session implements SessionFace {
})
this.derivedRev++
}
this.lastStepByTurn.delete(event.data.turn)
return
}
default:
@@ -931,6 +940,7 @@ export class Session implements SessionFace {
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.lastStepByTurn.clear()
this.callsRev++
this.derivedNodes = []
this.derivedRev++

View File

@@ -71,12 +71,6 @@ function materializeNode(
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', messageId: event.data.message.id,
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)

View File

@@ -12,7 +12,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
at(seq, { type: 'turn/start', data: { turn } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: text(body), source: { kind: 'user' },
@@ -82,7 +82,12 @@ export const ev = {
},
}),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
at(seq, { type: 'turn/end', data: {
turn,
reason: reason === 'completed'
? { kind: 'completed' }
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
} }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>

View File

@@ -2,12 +2,45 @@ import { createMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts'
import { inspectRequests } from '../src/client/sessions/request-inspection.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [
ev.user(baseSeq, 'loaded tail'),
at(baseSeq + 1, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
data: {
turn: 80,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'tail summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1])
expect(projection.contexts.map(context => ({
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ originSeq: undefined, nodes: [baseSeq] },
{ originSeq: baseSeq + 1, nodes: [baseSeq + 1] },
])
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
@@ -91,4 +124,35 @@ describe('projectConversationHistory', () => {
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('drops completed token payloads without changing inspection projections', () => {
const events = [
ev.user(0, 'before'),
ev.stepStart(1, 1, 0),
ev.chunkStart(2, 1),
ev.chunkText(3, 1, ''),
ev.chunkText(4, 1, 'first'),
ev.chunkText(5, 1, ' discarded'),
at(6, { type: 'assistant/chunk', data: {
turn: 1,
step: 0,
chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } },
} }),
ev.assistant(7, 1, 'first discarded'),
ev.compactSummary(8, 'summary', 0, 7),
ev.compactCheckpoint(9, 8, 0, 7),
ev.stepStart(10, 2, 0),
ev.chunkStart(11, 2),
ev.chunkText(12, 2, 'interrupted'),
ev.turnEnd(13, 2, 'aborted'),
]
const raw = events.map(event => ({ event }))
const compacted = compactHistoryInspectionEntries(raw)
expect(compacted.map(entry => entry.event.seq)).toEqual([
0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13,
])
expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw))
expect(inspectRequests(compacted)).toEqual(inspectRequests(raw))
})
})

View File

@@ -7,9 +7,7 @@ import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, MuxFrame, RpcId, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
@@ -17,7 +15,7 @@ import { FakeApiClient } from './fake-api.ts'
const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const iid = (id: string): InboxItemId => id as InboxItemId
const iid = (id: string): MessageId => id as MessageId
interface QueueFixture {
id: string
@@ -151,16 +149,16 @@ describe('queue snapshot intake', () => {
const durable = {
seq: 0,
time: 1_700_000_000_000,
type: 'steering/message',
type: 'user/message',
surfaceOp: 'append',
data: { turn: 1, message },
data: message,
} as SessionEvent
session.handleMuxEnvelope(rid('env-durable'), {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1)
expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1)
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
{ id: 's-later', body: '', placement: 'steering', message },
@@ -170,6 +168,32 @@ describe('queue snapshot intake', () => {
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
})
it('hands off live steering when the agent claims it as a user message', async () => {
const session = makeSession()
await session.open()
const message = createUserMessage({
content: text('claimed steering'),
source: { kind: 'user' },
})
session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
{ id: 's-claimed', body: '', placement: 'steering', message },
]))
session.handleMuxEnvelope(rid('env-user-message'), {
type: 'session/event',
sessionId: SID,
event: {
seq: 0,
time: 1_700_000_000_000,
type: 'user/message',
surfaceOp: 'append',
data: message,
},
})
expect(session.getSnapshot().queue).toEqual([])
})
})
describe('queue operation transport', () => {

View File

@@ -85,6 +85,56 @@ describe('inspectRequests', () => {
expect(snapshot.callSchemas.get('call-1')?.name).toBe('read')
})
it('does not promote a truncated resume or change header to the initial prompt', () => {
for (const reason of ['resume', 'change'] as const) {
const snapshot = inspectRequests(entriesOf([
at(10, 'step/start', { turn: 3, step: 1 }),
at(11, 'request/header', {
reason,
header: {
config: { provider: 'fake', model: 'model' },
system: 'tail-window prompt',
},
}),
]))
expect(snapshot.requests[0]).toMatchObject({
purpose: 'assistant',
prompt: { system: 'tail-window prompt' },
})
expect(snapshot.requests[0]).not.toHaveProperty('promptChange')
}
})
it('classifies a prompt change once the preceding header is loaded', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'request/header', {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'model' },
system: 'before',
},
}),
at(2, 'step/start', { turn: 1, step: 2 }),
at(3, 'request/header', {
reason: 'change',
header: {
config: { provider: 'fake', model: 'model' },
system: 'after',
},
}),
]))
expect(snapshot.requests[1]).toMatchObject({
promptChange: {
seq: 3,
kind: 'system',
previous: { system: 'before' },
},
})
})
it('preserves a standalone compaction owner without widening assistant turns', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'compact/start', { turn: null }),
@@ -225,20 +275,15 @@ describe('inspectRequests', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 1,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
turn: 1, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2,
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
}),
]))

View File

@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
it('loads the tail first and prepends older pages on demand', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
@@ -30,10 +30,21 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
expect(api.callsOf('session.history')).toHaveLength(1)
expect(source.getSnapshot().hasMore).toBe(true)
expect(source.getSnapshot().baseSeq).toBe(12)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([13, 15])
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
expect(source.getSnapshot().baseSeq).toBe(0)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
@@ -42,7 +53,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const before = source.getSnapshot()
source.handleMuxFrame({
@@ -60,7 +71,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
@@ -132,13 +143,14 @@ describe('SessionHistorySource', () => {
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
it('finishes an already started older page after consumer cancellation', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
@@ -151,7 +163,8 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await source.loadTail(controller.signal)
const complete = source.loadOlder(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
@@ -159,7 +172,7 @@ describe('SessionHistorySource', () => {
hasMore: true,
}))
await complete
expect(await complete).toBe(true)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)

View File

@@ -206,7 +206,7 @@ describe('live event path', () => {
expect(published).toEqual(['累计', null])
})
it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
const retryTurn = [
@@ -215,25 +215,13 @@ describe('live event path', () => {
ev.stepStart(8, 1),
ev.chunkStart(9, 1),
ev.chunkText(10, 1, '不完整回复'),
ev.stepEnd(11, 1),
ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
at(13, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error', step: 0,
failure: { code: 'TRANSPORT', message: '连接被重置' },
},
},
}),
at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
ev.stepStart(15, 2),
ev.assistant(16, 2, '完整回复'),
ev.stepEnd(17, 2),
ev.turnEnd(18, 2),
ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'),
ev.chunkStart(12, 1),
ev.assistant(13, 1, '完整回复'),
ev.stepEnd(14, 1),
ev.turnEnd(15, 1),
]
for (const event of retryTurn.slice(0, 7)) feed(event)
for (const event of retryTurn.slice(0, 6)) feed(event)
let snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
@@ -252,15 +240,14 @@ describe('live event path', () => {
})
expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
for (const event of retryTurn.slice(7)) feed(event)
for (const event of retryTurn.slice(6)) feed(event)
snapshot = session.getSnapshot()
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
const retryStart = retryTurn.find(event =>
event.type === 'turn/start' && event.data.trigger.kind === 'retry')
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include a retry turn/start')
const retryStart = retryTurn.find(event => event.type === 'turn/start')
if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start')
const retryEnd = retryTurn.find(event =>
event.type === 'turn/end' && event.data.turn === retryStart.data.turn)
if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn')
@@ -285,35 +272,33 @@ describe('live event path', () => {
const failedTurns = [
ev.turnStart(6, 1),
ev.user(7, '鉴权失败'),
at(8, {
ev.stepStart(8, 1),
at(9, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error',
step: 0,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
data: { turn: 1, reason: { kind: 'error', error: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
},
}),
ev.turnStart(9, 2),
ev.user(10, '内部失败'),
at(11, {
ev.turnStart(10, 2),
ev.user(11, '内部失败'),
ev.stepStart(12, 2, 1),
at(13, {
type: 'turn/end',
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
}),
]
for (const event of failedTurns) feed(event)
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
expect(errors).toMatchObject([
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
// Every failed turn carries a structured failure; unstructured errors
// flatten to the UNKNOWN code.
{ seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
])
expect('code' in errors[1]!).toBe(false)
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
@@ -450,7 +435,7 @@ describe('live event path', () => {
})
it.each(['aborted', 'disposed'] as const)(
'marks a scheduled retry as cancelled when its failed turn ends %s',
'marks a scheduled retry as cancelled when its failed turn receives the %s cause',
async (reason) => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
@@ -470,6 +455,24 @@ describe('live event path', () => {
},
)
it('marks a scheduled retry as started when its failed turn ends with an error', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.turnStart(6, 1))
feed(ev.retry(7, 1))
feed(at(8, {
type: 'turn/end',
data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
}))
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'model-retry',
retryState: 'started',
})
})
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -88,21 +88,14 @@ describe('TranscriptAdapter', () => {
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
ev.toolCall(3, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(4, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})

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: 3b4d2f2c1d7934d619768f2b3b355c8c585290cc
README.zh.md: e3664a0d621214cced2d8a0d7d5d5f7800f15d90
README.md: 4869fa4df929027f031082deb04cc1ab3d18921c
README.zh.md: 5c3091efa11b65f43ea5ea3037a60d0aa1bafbda

View File

@@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority.
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority.
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
@@ -69,4 +69,4 @@ None; this package neither assembles nor sends a provider request.
- **The approval panel has no durable grant control** — it supports allow-once and reject only.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels.
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.
- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `user/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority.

View File

@@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering中途引导操作已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
@@ -69,4 +69,4 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering中途引导操作会被保存和取消取代Enter 保存Escape 取消。
- **Queue 严格 steering 会保留完整消息**Agent 运行期间steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering直到已消费的 `steering/message` 折叠进持久 transcript文本记录因此立即展示、重连和回放共享同一个线性权威。
- **Queue 严格 steering 会保留完整消息**Agent 运行期间steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering直到已消费的 `user/message` 折叠进持久 transcript文本记录因此立即展示、重连和回放共享同一个线性权威。

View File

@@ -1,4 +1,4 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useEffect, useId, useRef, useState } from 'react'

View File

@@ -1,4 +1,4 @@
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// MessageItem: simple chat nodes — user bubbles
// (right-aligned, with clock + copy / branch IconActions), pending steering
// (copy only), context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
@@ -6,7 +6,7 @@
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,7 +19,6 @@ import css from './MessageItem.module.css'
export interface MessageItemProps {
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
@@ -227,7 +226,6 @@ export const MessageItem = memo(function MessageItem({
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user':
case 'steering':
return (
<UserStyleBubble
content={node.content}

View File

@@ -87,7 +87,6 @@ export function messageBranchSeqs(
nodeIndex++
}
if (tail?.kind === 'user'
|| (tail?.kind === 'steering' && tail.turn === turn)
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}

View File

@@ -1,6 +1,6 @@
/**
* Pure derivation of the terminal-card props from a frozen call slice: the
* `card:'terminal'` render intent the bash tool declares arrives on the
* `card:'terminal'` render intent the shell tools declare arrives on the
* snapshot as `callView`/`resultView`, and this is the one place that turns
* that pair into what {@link TerminalBlock} draws. Both conversation render
* sites (the chat tool row's expanded body and the details panel's Output

View File

@@ -31,6 +31,9 @@ export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
// The PowerShell twin is a shell tool: the bash row family (icon, colors)
// with its own title from TOOL_TITLES, not the generic `others` row.
pwsh: 'bash',
read: 'read',
web_fetch: 'read',
web_search: 'search',
@@ -49,6 +52,7 @@ const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
pwsh: 'Pwsh',
}
/**

View File

@@ -145,7 +145,7 @@ export function ConversationRoot({
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell t={t} />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
{inputBar}
</div>
)

View File

@@ -1,5 +1,5 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// Remaining chat branch tails: MessageItem context/unknown arms,
// user IconActions, StatsLine no-cache join,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
@@ -203,30 +203,6 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('consumed steering renders copy and branch actions without a badge', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
const ctxView = render(
<MessageItem t={t} node={{

View File

@@ -59,6 +59,7 @@ const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
describe('tool-call-model', () => {
it('classifies known tools and falls back to others', () => {
expect(classifyTool('bash')).toBe('bash')
expect(classifyTool('pwsh')).toBe('bash')
expect(classifyTool('read')).toBe('read')
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
@@ -71,6 +72,12 @@ describe('tool-call-model', () => {
expect(classifyTool('todo_write')).toBe('others')
})
it('gives the pwsh shell row the bash family treatment with its own title', () => {
const m = toolRowModel('pwsh', running())
expect(m.variant).toBe('bash')
expect(m.title).toBe('Pwsh')
})
it('derives state across running/ok/error/interrupted', () => {
expect(toolRowModel('bash', running()).state).toBe('running')
expect(toolRowModel('bash', result()).state).toBe('ok')

View File

@@ -274,11 +274,7 @@ describe('chat-flow derivation', () => {
user(6, 'second'),
assistant(7, 'clean tail', 2),
user(10, 'user-only tail'),
{
kind: 'steering', messageId: 'steering-tail' as never,
seq: 13, time: 13_000, turn: 4,
content: [{ type: 'text', text: 'steering tail' }], source: null,
},
user(13, 'steering tail'),
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7, 10, 13])
@@ -392,8 +388,7 @@ describe('ChatView', () => {
nodes: [
assistant(1, 'working'),
{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
kind: 'user', seq: 2, time: 2_000,
content: [{ type: 'text', text: 'interrupt now' }], source: null,
},
],
@@ -430,8 +425,7 @@ describe('ChatView', () => {
const h = makeHarness({
queue: [pending],
nodes: [{
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000, turn: 1,
kind: 'user', seq: 2, time: 2_000,
content: pending.content, source: null,
}],
running: true,
@@ -732,9 +726,13 @@ describe('ChatView', () => {
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
act(() => {
h.set({ nodes: [trigger, {
kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1,
content: [{ type: 'text', text: 'also' }], source: null,
h.set({ queue: [{
id: 'steering-occurrence' as never,
messageId: 'steering-message' as never,
placement: 'steering',
content: [{ type: 'text', text: 'also' }],
preview: 'also',
text: 'also',
}] })
})
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)

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-goal/README.md
README.md: c9a8f330949ed0db9c4986e7043a5063b8a26805
README.zh.md: 9df1a0091545436642ef5644d3258364e63a20ec
README.md: 0ea00b8bf9b07f02b5df0f7b3e7d3d9c6f109fde
README.zh.md: 70bf443118e5d2b1ce46e7bc1479bf932507b3f9

View File

@@ -8,11 +8,11 @@ The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar
## Model Experience
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content.
Indirectly, through the `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation commits in a durable `agent/inbox/spliced` insertion, which the goal projection folds immediately, and queues a `goal/change` context message. The model sees that context only if a later pre-step admits it; discarding the queued message does not roll back the projected state. The strip itself adds no prompt content.
#### KV Cache effect
None beyond the goal mutation's own context event, which appends to the log tail like any other message.
None unless the queued goal context is admitted. An admitted context extends the history tail like any other message; an insertion discarded before admission does not affect the cache.
## Known Limitations and Deferred Work

View File

@@ -8,11 +8,11 @@ Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.in
## 模型体验
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。
间接影响:条带动词提交的 `goal.edit`/`goal.pause`/`goal.resume`/`goal.clear` RPC 每次被接受后,变更都会在持久 `agent/inbox/spliced` 插入项中提交goal 投影会立即折叠该插入项,同时将一条 `goal/change` 上下文消息排队。只有后续 pre-step 准入该上下文时,模型才会看到它;丢弃已排队的消息不会回滚投影状态。条带自身不添加任何提示词内容。
#### KV Cache 影响
除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响
非已排队的 goal 上下文获准,否则没有影响。获准的上下文会像其他消息一样扩展历史尾部;准入前被丢弃的插入项不会影响缓存
## 已知限制与暂缓事项

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: d4e6c2508f07f4832f2e12a836c2d447b656421e
README.zh.md: 76dfbebd5e9db494b49d65a2528977b7ac9fed15
README.md: 03e7e3649fd0913fb48579aa87634153f67f5baf
README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `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, responsive images, 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 and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `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, responsive images, 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

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出
@@ -44,6 +44,6 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层
- **StateDot 没有 `Active` 变体**:支持的状态为 donewarningongoingerror。
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard``copyLabel`/`copiedLabel`)、`TerminalBlock``labels`)、`JsonTree``labels`)、`CodeBlock``copyLabel`/`copiedLabel`)、`MarkdownText``codeLabels`)、`JsonBlock``truncatedLabel`)、`ConnectionBanner``label`)和 `Modal``closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

View File

@@ -27,6 +27,11 @@
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",

View File

@@ -5,11 +5,16 @@ import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { CodeBlock } from './CodeBlock.tsx'
import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
const streamingRemarkPlugins = [remarkGfm]
const settledRemarkPlugins = [remarkGfm, remarkMath]
const settledRemarkPlugins = [
remarkGfm,
remarkMathCompatibility,
remarkMath,
]
const settledRehypePlugins = [rehypeKatex]
function sanitizeUrl(url: string): string {

View File

@@ -0,0 +1,353 @@
/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */
import { factorySpace } from 'micromark-factory-space'
import type {} from 'micromark-extension-math'
import { markdownLineEnding } from 'micromark-util-character'
import { codes, constants, types } from 'micromark-util-symbol'
import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types'
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
interface RemarkProcessor {
data(): { micromarkExtensions?: Extension[] }
}
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
/* v8 ignore next -- a previous code necessarily has a preceding event. */
if (tail === undefined) return false
return tail[1].type === types.characterEscape
}
const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) {
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the text construct is dispatched only for a backslash. */
if (code !== codes.backslash) return nok(code)
effects.enter('mathText')
effects.enter('mathTextSequence')
effects.consume(code)
return open
}
function open(code: number | null): State | undefined {
if (code !== codes.leftParenthesis) return nok(code)
effects.consume(code)
effects.exit('mathTextSequence')
return between
}
function between(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === codes.backslash) {
return effects.attempt({ partial: true, tokenize: tokenizeClose }, close, afterCloseAttempt)(code)
}
if (markdownLineEnding(code)) {
effects.enter(types.lineEnding)
effects.consume(code)
effects.exit(types.lineEnding)
return between
}
return dataStart(code)
}
function afterCloseAttempt(code: number | null): State | undefined {
return effects.check({ partial: true, tokenize: tokenizeOpen }, nok, dataStart)(code)
}
function dataStart(code: number | null): State | undefined {
effects.enter('mathTextData')
effects.consume(code)
return code === codes.backslash ? afterDataBackslash : data
}
function afterDataBackslash(code: number | null): State | undefined {
if (code === codes.backslash) {
effects.consume(code)
return data
}
return data(code)
}
function data(code: number | null): State | undefined {
if (code === codes.eof || code === codes.backslash || markdownLineEnding(code)) {
effects.exit('mathTextData')
return between(code)
}
effects.consume(code)
return data
}
function close(code: number | null): State | undefined {
effects.exit('mathText')
return ok(code)
}
function tokenizeClose(closeEffects: Parameters<Tokenizer>[0], closeOk: State, closeNok: State): State {
return slash
function slash(code: number | null): State | undefined {
/* v8 ignore next -- this partial construct is attempted only at a backslash. */
if (code !== codes.backslash) return closeNok(code)
closeEffects.enter('mathTextSequence')
closeEffects.consume(code)
return parenthesis
}
function parenthesis(code: number | null): State | undefined {
if (code !== codes.rightParenthesis) return closeNok(code)
closeEffects.consume(code)
closeEffects.exit('mathTextSequence')
return closeOk
}
}
function tokenizeOpen(openEffects: Parameters<Tokenizer>[0], openOk: State, openNok: State): State {
return slash
function slash(code: number | null): State | undefined {
/* v8 ignore next -- the opening check follows a failed close attempt at a backslash. */
if (code !== codes.backslash) return openNok(code)
openEffects.enter(types.chunkString)
openEffects.consume(code)
return parenthesis
}
function parenthesis(code: number | null): State | undefined {
if (code !== codes.leftParenthesis) return openNok(code)
openEffects.consume(code)
openEffects.exit(types.chunkString)
return openOk
}
}
}
function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct {
const tokenize: Tokenizer = function (effects, ok, nok) {
const self = this
let oddBackslashRun = false
const tail = self.events.at(-1)
const initialSize = tail?.[1].type === types.linePrefix
? tail[2].sliceSerialize(tail[1], true).length
: 0
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- the flow construct is dispatched only for its marker. */
if (code !== marker) return nok(code)
effects.enter('mathFlow')
effects.enter('mathFlowFence')
effects.enter('mathFlowFenceSequence')
effects.consume(code)
return open
}
function open(code: number | null): State | undefined {
if (code !== openMarker) return nok(code)
effects.consume(code)
effects.exit('mathFlowFenceSequence')
effects.exit('mathFlowFence')
return marker === codes.dollarSign ? afterDollarOpen : content
}
function afterDollarOpen(code: number | null): State | undefined {
return code === codes.dollarSign ? nok(code) : content(code)
}
function content(code: number | null): State | undefined {
if (code === codes.eof) return nok(code)
if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
afterClosingFenceAttempt,
)(code)
}
if (markdownLineEnding(code)) {
return multiline
? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code)
: nok(code)
}
return valueStart(code)
}
function afterClosingFenceAttempt(code: number | null): State | undefined {
return marker === codes.backslash
? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code)
: markerValueStart(code)
}
function afterContinuation(code: number | null): State | undefined {
return effects.attempt(
{ partial: true, tokenize: tokenizeClosingFence },
closed,
initialSize
? factorySpace(effects, content, types.linePrefix, initialSize + 1)
: content,
)(code)
}
function valueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = code === codes.backslash
effects.consume(code)
return value
}
function markerValueStart(code: number | null): State | undefined {
effects.enter('mathFlowValue')
oddBackslashRun = false
effects.consume(code)
return valueAfterMarker
}
function valueAfterMarker(code: number | null): State | undefined {
if (code === marker) {
effects.consume(code)
return value
}
return value(code)
}
function value(code: number | null): State | undefined {
if (code === codes.eof || code === marker || markdownLineEnding(code)) {
effects.exit('mathFlowValue')
return content(code)
}
oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false
effects.consume(code)
return value
}
function closed(code: number | null): State | undefined {
effects.exit('mathFlow')
return ok(code)
}
function tokenizeClosingFence(
closeEffects: Parameters<Tokenizer>[0],
closeOk: State,
closeNok: State,
): State {
return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize)
function sequenceStart(code: number | null): State | undefined {
if (code !== marker) return closeNok(code)
closeEffects.enter('mathFlowFence')
closeEffects.enter('mathFlowFenceSequence')
closeEffects.consume(code)
return sequenceEnd
}
function sequenceEnd(code: number | null): State | undefined {
if (code !== closeMarker) return closeNok(code)
closeEffects.consume(code)
closeEffects.exit('mathFlowFenceSequence')
return factorySpace(closeEffects, after, types.whitespace)
}
function after(code: number | null): State | undefined {
if (code !== codes.eof && !markdownLineEnding(code)) return closeNok(code)
closeEffects.exit('mathFlowFence')
return closeOk(code)
}
}
function tokenizeOpeningFence(
openEffects: Parameters<Tokenizer>[0],
openOk: State,
openNok: State,
): State {
return sequenceStart
function sequenceStart(code: number | null): State | undefined {
/* v8 ignore next -- the opening check follows a failed close attempt at the marker. */
if (code !== marker) return openNok(code)
openEffects.enter(types.chunkString)
openEffects.consume(code)
return sequenceEnd
}
function sequenceEnd(code: number | null): State | undefined {
if (code !== openMarker) return openNok(code)
openEffects.consume(code)
openEffects.exit(types.chunkString)
return openOk
}
}
}
return {
concrete: true,
name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow',
tokenize,
}
}
const tokenizeNonLazyContinuation: Tokenizer = function (effects, ok, nok) {
const self = this
return start
function start(code: number | null): State | undefined {
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (code === codes.eof) return ok(code)
/* v8 ignore next -- continuation constructs are attempted only after a line ending. */
if (!markdownLineEnding(code)) return nok(code)
effects.enter(types.lineEnding)
effects.consume(code)
effects.exit(types.lineEnding)
return lineStart
}
function lineStart(code: number | null): State | undefined {
return self.parser.lazy[self.now().line] ? nok(code) : ok(code)
}
}
const nonLazyContinuation: Construct = {
partial: true,
tokenize: tokenizeNonLazyContinuation,
}
const backslashMathText: Construct = {
name: 'backslashMathText',
previous: previousBackslash,
tokenize: tokenizeBackslashMathText,
}
const backslashMathFlow = createMathFlow(
codes.backslash,
codes.leftSquareBracket,
codes.rightSquareBracket,
true,
)
const sameLineDollarMathFlow = createMathFlow(
codes.dollarSign,
codes.dollarSign,
codes.dollarSign,
false,
)
const backslashMath: Extension = {
flow: {
[codes.backslash]: backslashMathFlow,
[codes.dollarSign]: sameLineDollarMathFlow,
},
text: { [codes.backslash]: backslashMathText },
}
/**
* Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
* The same processor must register remark-math to compile the emitted math tokens.
* @returns Nothing.
*/
export function remarkMathCompatibility(this: RemarkProcessor): undefined {
const data = this.data()
const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
extensions.push(backslashMath)
}

View File

@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { Extension } from 'micromark-util-types'
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
afterEach(cleanup)
@@ -171,6 +173,161 @@ describe('MarkdownText', () => {
expect(container.querySelector('a')).toBeNull()
})
it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
const source = [
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
'',
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
'',
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
'',
'| Symbol | Value |',
'| --- | --- |',
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
].join('\n')
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelectorAll('.katex')).toHaveLength(6)
expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
.toContain('\\tag{1}')
expect(container.querySelector('.katex-error')).toBeNull()
expect(container.querySelector('table .katex')).not.toBeNull()
})
it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
const cases = [
{
source: '\\(\\alpha \\, \\beta\\)',
math: 1,
display: 0,
},
{
source: String.raw`\\\(x\)`,
math: 1,
display: 0,
value: 'x',
},
{
source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
math: 1,
display: 0,
value: '\\frac{1}{5}\n+\\frac{1}{7}',
},
{
source: '\\[a\\\\\nb\\]',
math: 1,
display: 1,
value: 'a\\\\\nb',
},
{
source: '> \\[\n> \\frac{1}{5}\n> \\]',
math: 1,
display: 1,
},
{
source: '- \\[\n \\frac{1}{5}\n \\]',
math: 1,
display: 1,
},
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
if ('value' in item) {
expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
}
rendered.unmount()
}
const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
expect(literal.container.querySelector('.katex-display')).toBeNull()
expect(literal.container.textContent).toContain('[x')
})
it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
const cases = [
{ source: '$$\n\\theta\n$$', math: 1, display: 1 },
{ source: '$$$\\theta$$$', math: 1, display: 0 },
{ source: '$$a$b\nc', math: 0, display: 0 },
{ source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
{ source: '\\(\\theta', math: 0, display: 0 },
{ source: String.raw`\(a\\)`, math: 0, display: 0 },
{ source: '\\[\n\\[', math: 0, display: 0 },
{ source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
]
for (const item of cases) {
const rendered = render(<MarkdownText text={item.source} />)
expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
expect(rendered.container.querySelector('.katex-error')).toBeNull()
rendered.unmount()
}
})
it('lets display math interrupt an open paragraph', () => {
for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
const rendered = render(<MarkdownText text={source} />)
expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
rendered.unmount()
}
})
it('leaves a dollar block with trailing text to upstream inline math', () => {
const { container } = render(<MarkdownText text="$$x$$ trailing" />)
expect(container.querySelectorAll('.katex')).toHaveLength(1)
expect(container.querySelector('.katex-display')).toBeNull()
expect(container.querySelector('annotation')?.textContent).toBe('x')
expect(container.textContent).toContain('trailing')
})
it('renders escaped dollars and even backslash pairs before closing fences', () => {
const source = [
String.raw`$$100\$$$`,
'',
String.raw`\(a\\\)`,
'',
String.raw`\[b\\\]`,
].join('\n')
const { container } = render(<MarkdownText text={source} />)
const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
expect(container.querySelector('.katex-error')).toBeNull()
})
it('bounds fallback work for repeated unclosed backslash delimiters', () => {
const startedAt = performance.now()
const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
expect(performance.now() - startedAt).toBeLessThan(1_000)
expect(container.querySelector('.katex')).toBeNull()
})
it('leaves TeX-looking fenced code literal', () => {
const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
const { container } = render(<MarkdownText text={source} />)
expect(container.querySelector('.katex')).toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
})
it('registers the compatibility extension on a bare remark processor', () => {
const data: { micromarkExtensions?: Extension[] } = {}
remarkMathCompatibility.call({ data: () => data })
expect(data.micromarkExtensions).toHaveLength(1)
})
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\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: 771e1a68e8027f02f20b17f78d0a8dd48d2d5bff
README.zh.md: ceb4d696e5fda117afff89cf9cd0a5426dfed2be
README.md: 2c737fe2d04df518e7d32d07aaede714a7a3566d
README.zh.md: 738d6cafa0c6d44f05fecec01d565da91ef433e8

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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 standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. 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. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. 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.
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. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. 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. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. 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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩compaction请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验

View File

@@ -35,6 +35,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@tanstack/react-virtual": "^3.14.9",
"diff": "^9.0.0"
},
"peerDependencies": {
@@ -42,7 +43,8 @@
"@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"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
@@ -51,8 +53,10 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -13,6 +13,7 @@
}
.tablePane {
position: relative;
flex: 1;
min-width: 0;
overflow-x: hidden;
@@ -21,6 +22,53 @@
container: trajectory-table / inline-size;
}
.historyLoading {
position: sticky;
z-index: 5;
top: 0;
height: 0;
overflow: visible;
pointer-events: none;
}
.historyLoadingBar {
display: flex;
width: 100%;
height: 30px;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
}
.historyLoadingSpinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border: 1.5px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-state-business-primary);
border-radius: 50%;
animation: history-loading-spin 700ms linear infinite;
}
.table:not([data-scroll-ready='true']) {
visibility: hidden;
}
@keyframes history-loading-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.historyLoadingSpinner {
animation: none;
}
}
.table {
--trajectory-turn-accent: color-mix(
in srgb,
@@ -79,7 +127,17 @@
white-space: nowrap;
}
.table tbody tr:not([data-collapsed-summary]) {
.table tbody .virtualSpacer {
pointer-events: none;
}
.table tbody .virtualSpacer td {
height: var(--trajectory-virtual-spacer-height);
padding: 0;
border: 0;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
cursor: default;
outline: none;
transition:
@@ -91,7 +149,7 @@
opacity: 0.24;
}
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -106,7 +164,7 @@
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:last-child td {
.table tbody tr[data-terminal-request-boundary='true'] td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
}
@@ -620,6 +678,10 @@
white-space: nowrap;
}
.toolCallOnly {
color: var(--dsw-alias-label-tertiary);
}
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
@@ -936,6 +998,17 @@
overflow: auto;
}
.summaryScrollRegion {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
.summaryScrollRegion:hover,
.summaryScrollRegion:focus-within {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
@@ -1023,7 +1096,6 @@
margin: 0;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1033,7 +1105,6 @@
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
IconChevronRightOutline14,
IconSettingsOutline16,
@@ -18,11 +19,19 @@ import type {
import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts'
import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48
const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
@@ -117,6 +126,30 @@ interface TableRecord {
collapsedSummaryKind?: 'turn' | 'assistant'
}
interface VirtualRowStructure {
height: number
key: string
}
function useStableVirtualRowStructure(
rows: readonly TrajectoryVirtualRow<TableRecord>[],
): readonly VirtualRowStructure[] {
const cache = useRef<{
rows: readonly TrajectoryVirtualRow<TableRecord>[]
structure: readonly VirtualRowStructure[]
}>({ rows: [], structure: [] })
if (cache.current.rows === rows) return cache.current.structure
const structure = cache.current.structure.length === rows.length
&& rows.every((row, index) => {
const previous = cache.current.structure[index]
return previous?.key === row.key && previous.height === row.height
})
? cache.current.structure
: rows.map(row => ({ key: row.key, height: row.height }))
cache.current = { rows, structure }
return structure
}
type DetailTab =
| 'system-prompt'
| 'tools'
@@ -150,9 +183,8 @@ interface ToolCallTextParts {
interface SelectedRequest {
turn: number | null
section: number
number: number
group: string
seq?: number
}
interface DetailsResizeDrag {
@@ -195,6 +227,16 @@ type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
type VirtualSpacerStyle = CSSProperties & {
'--trajectory-virtual-spacer-height': string
}
interface OlderLoadAnchor {
readonly historyStartSeq: number | undefined
readonly scrollHeight: number
readonly scrollTop: number
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -228,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
return `${day} ${time}`
}
/** Whether a click lands on an active text selection and should keep it. */
function clickSelectsText(target: Node): boolean {
const selection = window.getSelection()
return selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(target)
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -238,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
if (clickSelectsText(event.currentTarget)) return
setShowUnix(current => !current)
}}
>
@@ -302,6 +347,8 @@ export interface TrajectoryTableProps {
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
turns: readonly TrajectoryTurnModel[]
/** In-flight cells whose content replaces the matching structural record index. */
streamingCells?: readonly TrajectoryCellProps[]
/** Record indexes emphasized by the active timeline focus. */
timelineFocusIndexes?: ReadonlySet<number> | null
/** Record indexes retained by the active live search, or null without a query. */
@@ -312,16 +359,26 @@ export interface TrajectoryTableProps {
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** One externally requested record focus without changing inspector selection. */
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
hasOlderRecords?: boolean
/** Load one older history page. */
onLoadOlder?: () => Promise<boolean>
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
onToggleTurn: (turn: number) => void
/** Assistant record indexes whose tool calls are folded. */
collapsedAssistants: ReadonlySet<number>
/** Stable Assistant record ids whose tool calls are folded. */
collapsedAssistants: ReadonlySet<string>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
onToggleAssistant: (id: string) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
@@ -492,7 +549,6 @@ function collapseTurnRecords(
records: readonly TableRecord[],
collapsedTurns: ReadonlySet<number>,
): TableRecord[] {
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
@@ -550,15 +606,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
collapsedAssistants: ReadonlySet<string>,
): TableRecord[] {
if (collapsedAssistants.size === 0) return [...records]
const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
if (record === undefined) continue
out.push(record)
if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue
if (
record.cell.kind !== 'message'
|| !collapsedAssistants.has(trajectoryRecordId(record.cell))
) continue
const calls: TableRecord[] = []
for (let j = i + 1; j < records.length; j++) {
const candidate = records[j]
@@ -1335,7 +1393,7 @@ function RequestTiming({
<dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div>
<div><dt>Duration</dt><dd></dd></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
</dl>
)
}
@@ -1519,7 +1577,12 @@ function OverviewSection({
<IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
</button>
</h3>
<div className={css.overviewPreview}>{children}</div>
<div
className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{children}
</div>
</section>
)
}
@@ -1533,11 +1596,17 @@ function OverviewSection({
export function TrajectoryTable({
requestNumbers: sessionRequestNumbers,
turns,
streamingCells = [],
timelineFocusIndexes = null,
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
recordFocus = null,
historyLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1546,7 +1615,7 @@ export function TrajectoryTable({
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(false)
@@ -1554,20 +1623,116 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const [tableScrollReady, setTableScrollReady] = useState(false)
const pendingScrollRecordId = useRef<string | null>(null)
const loadingOlder = useRef(false)
const [olderLoading, setOlderLoading] = useState(false)
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const streamingCellsByIndex = useMemo(
() => new Map(streamingCells.map(cell => [cell.index, cell])),
[streamingCells],
)
const currentRecord = useCallback((record: TableRecord): TableRecord => {
const cell = streamingCellsByIndex.get(record.cell.index)
return cell === undefined ? record : { ...record, cell }
}, [streamingCellsByIndex])
const selectedTemplate = useMemo(() => selectedRecordId === null
? undefined
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId),
[allRecords, selectedRecordId])
const selected = selectedTemplate === undefined
? undefined
: currentRecord(selectedTemplate)
const selectedIndex = selected?.cell.index ?? null
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
collapseTurnRecords(allRecords, collapsedTurns),
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
const turnRecords = collapsedTurns.size === 0
? allRecords
: collapseTurnRecords(allRecords, collapsedTurns)
return collapsedAssistants.size === 0
? turnRecords
: collapseAssistantRecords(turnRecords, collapsedAssistants)
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
const projectedVirtualRows = useMemo(
() => groupTrajectoryVirtualRows(records),
[records],
)
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
const virtualizationEnabled = hasOlderRecords
|| records.length > VIRTUALIZATION_THRESHOLD
const estimateVirtualRowSize = useCallback(
(index: number) => virtualRowStructure[index]?.height ?? 30,
[virtualRowStructure],
)
const getVirtualRowKey = useCallback(
(index: number) => virtualRowStructure[index]?.key ?? index,
[virtualRowStructure],
)
const getTableScrollElement = useCallback(() => tablePaneRef.current, [])
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: virtualizationEnabled ? virtualRowStructure.length : 0,
enabled: virtualizationEnabled,
estimateSize: estimateVirtualRowSize,
getItemKey: getVirtualRowKey,
getScrollElement: getTableScrollElement,
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
anchorTo: 'end',
overscan: VIRTUAL_OVERSCAN_ROWS,
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
})
const virtualIndexByRecordId = useMemo(() => {
const indexes = new Map<string, number>()
for (const [virtualIndex, row] of projectedVirtualRows.entries()) {
for (const entry of row.entries) {
if (entry.record.collapsedSummary === undefined) {
indexes.set(trajectoryRecordId(entry.record.cell), virtualIndex)
}
}
}
return indexes
}, [projectedVirtualRows])
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualItems[0]?.start ?? 0
const virtualBottom = virtualItems.length === 0
? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
const renderedRecords = virtualizationEnabled
? virtualItems.flatMap((item) => {
const row = projectedVirtualRows[item.index]
if (row === undefined) return []
return row.entries.map((entry, entryIndex) => ({
record: currentRecord(entry.record),
position: entry.logicalIndex,
terminalRequestBoundary:
entry.record.cell.requestOnly === true
&& row.entries.at(-1)?.record.cell.requestOnly === true
&& entryIndex === row.entries.length - 1,
}))
})
: records.map((record, position) => ({
record: currentRecord(record),
position,
terminalRequestBoundary:
record.cell.requestOnly === true && position === records.length - 1,
}))
const requestBoundaryRuns = useMemo(
() => indexRequestBoundaryRuns(records),
[records],
)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
@@ -1576,20 +1741,25 @@ export function TrajectoryTable({
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestRecords = selectedRequest === null
const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
&& record.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
), [allRecords, selectedRequest])
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestNumber = selectedRequest === null
? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
: sessionRequestNumbers?.find(request => selectedRequest.seq === undefined
? request.turn === selectedRequest.turn && request.group === selectedRequest.group
: request.seq === selectedRequest.seq)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestInfo?.status
@@ -1605,9 +1775,12 @@ export function TrajectoryTable({
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool',
).length
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
const selectedRequestResultTemplate = selectedRequestInfo?.resultSeq === undefined
? selectedRequestAssistant
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
const selectedRequestResult = selectedRequestResultTemplate === undefined
? undefined
: currentRecord(selectedRequestResultTemplate)
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
selectedRequestAssistant === undefined
? undefined
@@ -1633,7 +1806,9 @@ export function TrajectoryTable({
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
const activeSection = selectedRequest === null
? selected?.section
: selectedRequestRecords[0]?.section
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1645,13 +1820,17 @@ export function TrajectoryTable({
const selectedAssistantRequest = selected?.cell.kind === 'message'
? requestNumbers.get(requestKey(selected.turn, selected.group))
: undefined
const selectedAssistantRequestInfo = selectedAssistantRequest === undefined
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
section: selected.section,
number: selectedAssistantRequest,
group: selected.group,
...(selectedAssistantRequestInfo?.seq === undefined
? {}
: { seq: selectedAssistantRequestInfo.seq }),
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
@@ -1670,7 +1849,7 @@ export function TrajectoryTable({
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(null)
}
@@ -1683,7 +1862,7 @@ export function TrajectoryTable({
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
setSelectedIndex(index)
setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell))
if (record === undefined) return
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
@@ -1697,13 +1876,25 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordSelection, selectRecord])
useEffect(() => {
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
appliedRecordFocus.current = recordFocus
const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordFocus])
const selectRequest = (
request: SelectedRequest,
tab: 'overview' | 'timing' = 'overview',
) => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(request)
activateTab(tab)
}
@@ -1716,12 +1907,13 @@ export function TrajectoryTable({
const candidate = allRecords[i]
if (candidate === undefined || candidate.turn !== target.turn) break
if (candidate.cell.kind !== 'message') continue
if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index)
const assistantId = trajectoryRecordId(candidate.cell)
if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId)
break
}
}
setSelectedRequest(null)
setSelectedIndex(target.cell.index)
setSelectedRecordId(trajectoryRecordId(target.cell))
activateTab('overview')
}
@@ -1734,11 +1926,6 @@ export function TrajectoryTable({
// open its summary, and remember the row to scroll once the un-collapsed
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
@@ -1746,61 +1933,213 @@ export function TrajectoryTable({
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
pendingScrollRecordId.current = trajectoryRecordId(target.cell)
onInspectApplied?.()
}, [inspectCallId, turns, onInspectApplied])
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
const id = pendingScrollRecordId.current
if (id === null) return
const position = records.findIndex(record =>
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
if (position === -1) return
if (virtualizationEnabled) {
const virtualIndex = virtualIndexByRecordId.get(id)
if (virtualIndex === undefined) return
pendingScrollRecordId.current = null
followsTableTail.current = false
rowVirtualizer.scrollToIndex(virtualIndex, { behavior: 'smooth', align: 'center' })
return
}
pendingScrollRecordId.current = null
followsTableTail.current = false
const recordIndex = records[position]?.cell.index
const row = recordIndex === undefined
? null
: rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`)
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const focusedPositions = records.flatMap((record, position) =>
record.collapsedSummary === undefined
&& record.cell.requestOnly !== true
&& timelineFocusIndexes.has(record.cell.index)
? [position]
: [])
const first = focusedPositions.at(0)
const last = focusedPositions.at(-1)
if (first === undefined || last === undefined) return
if (!virtualizationEnabled) {
const ledger = rootRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const firstRow = focusedRows.at(0)
const lastRow = focusedRows.at(-1)
if (firstRow === undefined || lastRow === undefined) return
const focusHeight =
lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top
const target = focusHeight > ledger.clientHeight
? firstRow
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (target !== undefined && typeof target.scrollIntoView === 'function') {
followsTableTail.current = false
target.scrollIntoView({
behavior: 'smooth',
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
})
}
return
}
const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => {
const record = records[position]
if (record === undefined) return []
const virtualIndex = virtualIndexByRecordId.get(trajectoryRecordId(record.cell))
return virtualIndex === undefined ? [] : [virtualIndex]
}))].sort((left, right) => left - right)
const firstVirtual = focusedVirtualIndexes.at(0)
const lastVirtual = focusedVirtualIndexes.at(-1)
if (firstVirtual === undefined || lastVirtual === undefined) return
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
const focusHeight = projectedVirtualRows
.slice(firstVirtual, lastVirtual + 1)
.reduce((height, row) => height + row.height, 0)
followsTableTail.current = false
rowVirtualizer.scrollToIndex(
focusHeight > paneHeight
? firstVirtual
: focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)]
?? firstVirtual,
{
behavior: 'smooth',
align: focusHeight > paneHeight ? 'start' : 'center',
},
)
}, [
projectedVirtualRows,
records,
rowVirtualizer,
timelineFocusIndexes,
virtualIndexByRecordId,
virtualizationEnabled,
])
const requestOlder = useCallback((pane: HTMLDivElement) => {
if (
!hasOlderRecords
|| onLoadOlder === undefined
|| loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
) return
loadingOlder.current = true
setOlderLoading(true)
olderLoadAnchor.current = {
historyStartSeq,
scrollHeight: pane.scrollHeight,
scrollTop: pane.scrollTop,
}
void onLoadOlder().then((advanced) => {
if (!advanced) olderLoadAnchor.current = null
}).finally(() => {
loadingOlder.current = false
setOlderLoading(false)
})
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
if (!tableScrollInitialized.current) {
tableScrollInitialized.current = true
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
const anchor = olderLoadAnchor.current
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
if (!virtualizationEnabled) {
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
}
olderLoadAnchor.current = null
followsTableTail.current = false
return
}
if (followsTableTail.current) pane.scrollTop = pane.scrollHeight
}, [turns])
if (!tableScrollInitialized.current) {
if (historyLoading) return
tableScrollInitialized.current = true
followsTableTail.current = true
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
setTableScrollReady(true)
return
}
if (!followsTableTail.current) return
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
}, [
historyLoading,
historyStartSeq,
rowVirtualizer,
virtualRowStructure,
virtualizationEnabled,
])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
ref={tablePaneRef}
className={css.tablePane}
data-trajectory-scroll=""
onScroll={(event) => {
const pane = event.currentTarget
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane)
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
<table className={css.table}>
{showLoading && (
<div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel}
</span>
</div>
)}
<table
className={css.table}
data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length}
>
<colgroup>
<col className={css.eventColumn} />
<col className={css.contentColumn} />
</colgroup>
<tbody>
{records.map((record) => {
{virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualTop}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
const displayText = recordDisplayText(record.cell)
const toolCallOnly = isToolCallOnly(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
@@ -1824,15 +2163,15 @@ export function TrajectoryTable({
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.section === record.section
&& selectedRequest.number === request
&& selectedRequest.group === record.group
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
@@ -1840,10 +2179,13 @@ export function TrajectoryTable({
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
data-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-terminal-request-boundary={terminalRequestBoundary || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
@@ -1860,7 +2202,7 @@ export function TrajectoryTable({
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
@@ -1875,7 +2217,7 @@ export function TrajectoryTable({
&& assistantToolCalls(allRecords, record.cell.index).length > 0
) {
event.preventDefault()
onToggleAssistant(record.cell.index)
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
@@ -1894,7 +2236,7 @@ export function TrajectoryTable({
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
@@ -1917,9 +2259,8 @@ export function TrajectoryTable({
event.stopPropagation()
selectRequest({
turn: record.turn,
section: record.section,
number: request,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
@@ -2013,8 +2354,8 @@ export function TrajectoryTable({
: `${listDisplayText}${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
@@ -2047,6 +2388,16 @@ export function TrajectoryTable({
</tr>
)
})}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualBottom}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
</tbody>
</table>
</div>
@@ -2141,7 +2492,7 @@ export function TrajectoryTable({
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
Request #{selectedRequestNumber ?? '—'}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
@@ -2220,7 +2571,10 @@ export function TrajectoryTable({
&& selectedRequestState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2371,7 +2725,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2388,7 +2745,10 @@ export function TrajectoryTable({
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<div
className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<MarkdownRecordContent
record={selected}
rendered
@@ -2406,7 +2766,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
@@ -2441,7 +2804,7 @@ export function TrajectoryTable({
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<span>Request #{selectedAssistantRequest ?? '—'}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}

View File

@@ -61,6 +61,46 @@
cursor: grabbing;
}
.earlierHistory {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 28px;
align-items: center;
justify-content: flex-start;
appearance: none;
box-sizing: border-box;
padding-left: 3px;
border: 0;
outline: none;
background: linear-gradient(
to right,
var(--dsw-alias-bg-layer-2) 0,
var(--dsw-alias-bg-layer-2) 38%,
transparent 100%
);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
line-height: 1;
opacity: 0.72;
cursor: pointer;
}
.earlierHistory:hover {
opacity: 1;
}
.earlierHistory[aria-disabled='true'] {
cursor: default;
}
.earlierHistory:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
}
.empty {
position: absolute;
top: 50%;

View File

@@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
/** Whether the loaded timeline omits an earlier history prefix. */
hasEarlierRecords?: boolean
/** Load one earlier history page from the truncation control. */
onLoadEarlier?: () => Promise<boolean>
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
@@ -191,11 +195,49 @@ function LaneLabels() {
)
}
function EarlierHistoryBoundary({
loading,
onHover,
onLoad,
}: {
loading: boolean
onHover: () => void
onLoad: (() => void) | undefined
}) {
return (
<Tooltip
label={loading ? 'Loading earlier history…' : 'Click to load earlier history'}
side="right"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<button
type="button"
className={css.earlierHistory}
data-earlier-history
data-loading={loading || undefined}
aria-label={loading ? 'Loading earlier history' : 'Load earlier history'}
aria-disabled={loading || onLoad === undefined}
onClick={onLoad}
onPointerEnter={(event) => {
event.stopPropagation()
onHover()
}}
onPointerMove={(event) => { event.stopPropagation() }}
onPointerDown={(event) => { event.stopPropagation() }}
>
</button>
</Tooltip>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
hasEarlierRecords = false,
onLoadEarlier,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
@@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
@@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const showsEarlierBoundary = hasEarlierRecords
&& model !== null
&& domainStart === model.start
const loadEarlier = onLoadEarlier === undefined || loadingEarlier
? undefined
: () => {
setLoadingEarlier(true)
void onLoadEarlier().finally(() => { setLoadingEarlier(false) })
}
const projectedDomainStyle = model === null
? undefined
: {
@@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
{hasEarlierRecords && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
</div>
</div>
</section>
@@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
event.preventDefault()
}}
>
{showsEarlierBoundary && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
@@ -609,7 +675,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const widthPercent = Math.max(width * 100, 0.35)
const widthPercent = width * 100
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
@@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
@@ -17,15 +17,51 @@ import {
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
type TrajectoryTurnModel,
} from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) last = Math.max(last, cell.index)
}
}
return last
}
function timelineBlock(block: AssistantBlock): AssistantBlock {
switch (block.kind) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return {
kind: 'tool-call',
callId: block.callId,
name: block.name,
argsRaw: '',
}
case 'other': return { kind: 'other', block: null }
}
}
function partialStructureSignature(partial: ConversationSnapshot['partial']): string {
if (partial === null) return ''
return partial.blocks.map(block => block.kind === 'tool-call'
? `${block.kind}:${block.callId}:${block.name}`
: block.kind).join('\u0000')
}
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
@@ -33,7 +69,8 @@ export interface TrajectoryViewInjected {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void>
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
@@ -137,14 +174,23 @@ function searchMatches(
return matches
}
function mergeSearchMatches(
finalized: ReadonlySet<number> | null,
partial: ReadonlySet<number> | null,
): ReadonlySet<number> | null {
if (finalized === null || partial === null) return null
return new Set([...finalized, ...partial])
}
export function TrajectoryView({
useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const actualDuration = useDuration(value => value)
@@ -154,26 +200,36 @@ export function TrajectoryView({
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
historyControllerRef.current = controller
void loadHistoryTailRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -183,18 +239,18 @@ export function TrajectoryView({
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) {
for (const node of interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
@@ -295,63 +351,74 @@ export function TrajectoryView({
})
}
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,
contexts, nodes, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
? null
: {
turn: partial.turn,
step: partial.step,
blocks: partial.blocks.map(block => timelineBlock(block)),
},
[partialStep, partialTurn, timelinePartialSignature])
const timelineTurns = useMemo(
() => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex),
[finalized, timelinePartial],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
const streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells),
),
[partialSearchTurns],
)
const partialSearchMatches = useMemo(
() => searchMatches(partialSearchTurns, searchQuery),
[partialSearchTurns, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
)
const timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
: trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode),
[timelineMode, timelineRange, timelineTurns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
@@ -361,31 +428,22 @@ export function TrajectoryView({
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 handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
}, [])
const handleTimelineRecordFocus = useCallback((index: number) => {
setTimelineRecordFocus({ index })
}, [])
const collapsibleTurnIds = useMemo(
() => turns
() => timelineTurns
.filter(turn =>
turn.turn !== null
&&
@@ -396,23 +454,25 @@ export function TrajectoryView({
0,
) > 1)
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
[turns],
[timelineTurns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
const ids: string[] = []
for (const turn of timelineTurns) {
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)
if (next?.kind === 'tool' || next?.kind === 'subtool') {
ids.push(trajectoryRecordId(cell))
}
}
}
return ids
}, [turns])
}, [timelineTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -437,11 +497,11 @@ export function TrajectoryView({
})
}
const toggleAssistant = (index: number) => {
const toggleAssistant = (id: string) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
if (collapsed.has(id)) collapsed.delete(id)
else collapsed.add(id)
return collapsed
})
}
@@ -458,6 +518,13 @@ export function TrajectoryView({
})
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return (
<div className={css.root} data-conversation-composer-overlay="">
<TrajectoryToolbar
@@ -479,45 +546,33 @@ export function TrajectoryView({
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
turns={timelineTurns}
mode={timelineMode}
range={timelineRange}
hasEarlierRecords={hasOlderHistory}
onLoadEarlier={loadEarlierHistory}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(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' })
}
}}
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' })
}
}}
onRangeChange={handleTimelineRangeChange}
onRecordSelect={handleTimelineRecordSelect}
onRecordFocus={handleTimelineRecordFocus}
/>
<div ref={ledgerRef} className={css.ledger}>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={turns}
turns={timelineTurns}
streamingCells={streamingCells}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -7,6 +7,8 @@ import type {
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
@@ -18,6 +20,7 @@ export interface TrajectoryContextBranch {
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
@@ -63,6 +66,9 @@ export function deriveTrajectoryContextBranches(
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
@@ -84,6 +90,7 @@ export function deriveTrajectoryContextBranches(
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),

View File

@@ -35,7 +35,8 @@ export function apply(ctx: Context): void {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history, duration },
loadAllHistory: signal => history.loadAll(signal),
loadHistoryTail: signal => history.loadTail(signal),
loadOlderHistory: signal => history.loadOlder(signal),
setActualDuration: (value) => { duration.set(value) },
}
},

View File

@@ -17,6 +17,7 @@ import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -75,7 +76,7 @@ const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
{ kind: 'user' | 'context' }
>
type OrderedLayoutEntry =
@@ -325,19 +326,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
const { node, nodeIndex: i } = entry
if (node.kind === 'user' || node.kind === 'steering') {
if (node.kind === 'user') {
// 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.
const turn = node.kind === 'steering'
? node.turn
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
pushMessage(turn, {
absTime: finiteTime(node.time),
cell: {
index: ++index,
kind: 'user',
...inputCellDetail(node),
opensTurn: node.kind === 'user',
opensTurn: true,
},
})
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
@@ -453,7 +452,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
else for (const laid of laidList) pushMessage(call.turn, laid)
}
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
// Orphan turn-0 cells (orphaned tools) fold into Turn 1.
const prologue = turns.get(0)
if (prologue !== undefined) {
turns.delete(0)
@@ -475,6 +474,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
/**
* Append the changing in-flight assistant cells to a stable finalized layout.
* @param turns - Finalized layout derived with an empty-block partial anchor.
* @param partial - Current in-flight assistant projection.
* @param lastIndex - Highest cell index in the finalized layout.
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
*/
export function appendTrajectoryPartialLayout(
turns: readonly TrajectoryTurnModel[],
partial: ConversationSnapshot['partial'],
lastIndex: number,
): readonly TrajectoryTurnModel[] {
if (partial === null) return turns
const partialTurn = deriveTrajectoryLayout({
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
...partialTurn,
groups: partialTurn.groups.map(group => ({
...group,
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
})),
}
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
if (turnIndex === -1) return [...turns, streamed]
const current = turns[turnIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (current === undefined) return turns
const groups = [...current.groups]
for (const streamedGroup of streamed.groups) {
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
if (groupIndex === -1) {
groups.push(streamedGroup)
continue
}
const group = groups[groupIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (group === undefined) continue
const streamedCallIds = new Set(
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
)
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell =>
cell.requestOnly !== true
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
),
...streamedGroup.cells,
],
}
}
const updated = [...turns]
updated[turnIndex] = { ...current, groups }
return updated
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
@@ -542,9 +602,7 @@ 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`
return formatElapsedSeconds(seconds)
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -566,6 +624,7 @@ function expandAssistant(
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
@@ -585,6 +644,7 @@ function expandAssistant(
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
@@ -733,7 +793,7 @@ function stringifySourceValue(value: unknown): string {
}
/**
* Turn that encloses a user/message: next assistant/steering turn, else the
* Turn that encloses a user/message: next assistant turn, else the
* in-flight partial, else the turn after the last finalized assistant (or 1).
*/
function enclosingUserTurn(
@@ -746,7 +806,7 @@ function enclosingUserTurn(
const n = 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 (n === undefined) continue
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
if (n.kind === 'assistant') return n.turn
}
if (partial !== null) return partial.turn
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
@@ -770,7 +830,7 @@ function firstVisibleTurn(
partial: ConversationSnapshot['partial'],
): number {
const turns = nodes.flatMap(node =>
(node.kind === 'assistant' || node.kind === 'steering') && node.turn > 0
node.kind === 'assistant' && node.turn > 0
? [node.turn]
: [],
)

View File

@@ -1,6 +1,7 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import { formatDurationMillis } from './trajectory-record.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
@@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
}
/**
* Format a timeline duration with a compact unit.
* Format a timeline duration as an integer-millisecond label.
* @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label.
* @returns Millisecond label with thousands separators.
*/
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`
return formatDurationMillis(milliseconds)
}
function laneFor(kind: TrajectoryCellKind): number {

View File

@@ -37,6 +37,8 @@ export interface TrajectorySourceBlock {
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based record index shown as `#N`. */
index: number
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
@@ -92,13 +94,32 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
}
/**
* Format own-duration for the trailing time column.
* Resolve the identity that survives prepending older projected records.
* @param cell - Projected trajectory record.
* @returns Stable identity from the owning event or tool call, with a fixture fallback.
*/
export function trajectoryRecordId(cell: TrajectoryCellProps): string {
if (cell.recordId !== undefined) return cell.recordId
if (cell.callId !== undefined) return `${cell.kind}\u0000call\u0000${cell.callId}`
if (cell.sourceSeq !== undefined) return `${cell.kind}\u0000seq\u0000${cell.sourceSeq}`
return `${cell.kind}\u0000index\u0000${cell.index}`
}
/**
* Format a duration in milliseconds with thousands separators.
* @param milliseconds - Duration in milliseconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
}
/**
* Format an elapsed duration given in seconds as a millisecond label.
* @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise a seconds label.
* @returns `—` when unknown, otherwise an integer-millisecond 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`
return formatDurationMillis(seconds === null ? null : seconds * 1000)
}

View File

@@ -0,0 +1,83 @@
/** Pure projection from trajectory records to measurable virtual ledger rows. */
import type { TrajectoryCellProps } from './trajectory-record.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
const CONTENT_ROW_HEIGHT = 30
const COLLAPSED_SUMMARY_HEIGHT = 20
const TERMINAL_BOUNDARY_HEIGHT = 9
/** Minimal record shape required by the trajectory virtual-row projection. */
export interface VirtualizableTrajectoryRecord {
cell: TrajectoryCellProps
collapsedSummaryKind?: 'turn' | 'assistant'
}
/** One logical record retained inside a measurable virtual row. */
export interface TrajectoryVirtualRowEntry<T extends VirtualizableTrajectoryRecord> {
logicalIndex: number
record: T
}
/** One virtualizer item, which may carry zero-height request boundaries. */
export interface TrajectoryVirtualRow<T extends VirtualizableTrajectoryRecord> {
entries: readonly TrajectoryVirtualRowEntry<T>[]
height: number
key: string
}
/**
* Derive the DOM-safe row identity shared by React, the virtualizer, and
* browser scroll contracts.
* @param record - Display record whose identity is required.
* @returns Stable record identity with a suffix for synthetic fold summaries.
*/
export function trajectoryVirtualRecordKey(
record: VirtualizableTrajectoryRecord,
): string {
const identity = encodeURIComponent(trajectoryRecordId(record.cell))
return record.collapsedSummaryKind === undefined
? identity
: `${identity}\u0000summary\u0000${record.collapsedSummaryKind}`
}
/**
* Attach separator-only records to the next content row so the virtualizer
* never owns a zero-height item. A terminal separator retains its CSS-owned
* lower-marker clearance as a standalone item.
* @param records - Final search/fold projection in ledger order.
* @returns Measurable virtual rows with original logical positions retained.
*/
export function groupTrajectoryVirtualRows<T extends VirtualizableTrajectoryRecord>(
records: readonly T[],
): readonly TrajectoryVirtualRow<T>[] {
const rows: TrajectoryVirtualRow<T>[] = []
let pending: TrajectoryVirtualRowEntry<T>[] = []
for (const [logicalIndex, record] of records.entries()) {
const entry = { logicalIndex, record }
if (record.cell.requestOnly === true) {
pending.push(entry)
continue
}
const entries = [...pending, entry]
pending = []
rows.push({
entries,
height: record.collapsedSummaryKind === undefined
? CONTENT_ROW_HEIGHT
: COLLAPSED_SUMMARY_HEIGHT,
key: trajectoryVirtualRecordKey(record),
})
}
if (pending.length > 0) {
rows.push({
entries: pending,
height: TERMINAL_BOUNDARY_HEIGHT,
key: pending.map(candidate => trajectoryVirtualRecordKey(candidate.record)).join('|'),
})
}
return rows
}

View File

@@ -10,17 +10,33 @@ import {
TrajectoryCell,
type TrajectoryCellKind,
} from '../src/client/TrajectoryCell.tsx'
import { formatDurationMillis } from '../src/client/trajectory-record.ts'
afterEach(cleanup)
describe('formatDurationMillis', () => {
it('formats exact millisecond labels with thousands separators', () => {
expect(formatDurationMillis(0)).toBe('0 ms')
expect(formatDurationMillis(29)).toBe('29 ms')
expect(formatDurationMillis(500)).toBe('500 ms')
expect(formatDurationMillis(1_500)).toBe('1,500 ms')
expect(formatDurationMillis(235_200)).toBe('235,200 ms')
expect(formatDurationMillis(null)).toBe('—')
expect(formatDurationMillis(Number.NaN)).toBe('—')
})
})
describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—')
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(235)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.0)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.2)).toBe('235,200 ms')
expect(formatElapsedSeconds(235.25)).toBe('235,250 ms')
expect(formatElapsedSeconds(0)).toBe('0 ms')
expect(formatElapsedSeconds(0.029)).toBe('29 ms')
expect(formatElapsedSeconds(0.5)).toBe('500 ms')
expect(formatElapsedSeconds(1.5)).toBe('1,500 ms')
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
})
})
@@ -38,7 +54,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('5 s')).toBeTruthy()
expect(screen.getByText('5,000 ms')).toBeTruthy()
})
it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -57,11 +73,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy()
expect(screen.getByText('235,200 ms')).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.2 s'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235,200 ms'))
})
it('selected marks the row for the brand-primary inset ring', () => {

View File

@@ -46,6 +46,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['react-dom', await import('react-dom')],
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])

View File

@@ -71,6 +71,7 @@ describe('trajectory context branches', () => {
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.key).toBe('rewind:110')
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
@@ -85,4 +86,15 @@ describe('trajectory context branches', () => {
request('assistant', 111),
)).toBe(true)
})
it('keeps branch identity when prepended generations shift local ids', () => {
const branch = (id: number) => deriveTrajectoryContextBranches([{
id,
origin: 'rewind',
originSeq: 110,
nodes: [current],
}])[0]
expect(branch(1)?.key).toBe(branch(9)?.key)
})
})

View File

@@ -11,7 +11,9 @@ import type {
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
} from '../src/client/layout.ts'
afterEach(cleanup)
@@ -102,6 +104,70 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
const nodes = [{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'finalized' }],
}] as unknown as ConversationSnapshot['nodes']
const partial = {
turn: 2,
step: 1,
blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
}
const request = {
purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
runningCalls: [],
})
expect(base).toHaveLength(1)
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
expect(streamed[0]).toBe(base[0])
expect(streamed).toHaveLength(2)
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
index: 2,
kind: 'message',
text: 'streaming',
timeSeconds: null,
}])
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
})
it('replaces a running-call placeholder with the matching streamed tool call', () => {
const partial = {
turn: 1,
step: 1,
blocks: [{
kind: 'tool-call' as const,
callId: 'c1',
name: 'bash',
argsRaw: '{"command":"pwd"}',
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
}],
})
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
const cells = streamed[0]?.groups[0]?.cells ?? []
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
})
it('omits duration when node times are missing instead of rendering NaN', () => {
const nodes = [
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
@@ -141,7 +207,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('3 s bash×2')
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {

View File

@@ -2,11 +2,15 @@
/** 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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
})
const TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
@@ -56,11 +60,33 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
collapsedAssistants: new Set<string>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows a muted placeholder for an assistant response containing only tool calls', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'Tool call only',
sourceBlocks: [{
type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
}],
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(screen.getByText('(tool call only)')).toBeTruthy()
})
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
@@ -71,6 +97,27 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('shows a tool record Duration as exact milliseconds', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
timeSeconds: 1.5,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
@@ -83,6 +130,17 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const panel = screen.getByRole('tabpanel')
expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1)
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
@@ -163,6 +221,93 @@ describe('TrajectoryTable', () => {
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps the selected record when older rows shift projection indexes', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'selected tail response',
outputDetail: 'selected tail response detail',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Message',
cells: [{
index: 1,
kind: 'user',
sourceSeq: 1,
text: 'older prompt',
timeSeconds: 0,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('row', { name: /selected tail response/ })
.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('selected tail response detail')).toBeTruthy()
})
it('keeps a selected request when prepending changes its display number', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'tail response',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
sourceSeq: 1,
text: 'older response',
timeSeconds: 1,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('button', { name: 'Request #2' })
.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('Request #2')).toBeTruthy()
})
it('follows appended records only while the ledger is already at the bottom', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
@@ -210,6 +355,216 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(20)
})
it('preserves the visible anchor when the last older page disables virtualization', async () => {
let resolveOlder: ((advanced: boolean) => void) | undefined
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
const onLoadOlder = vi.fn(() => older)
const view = render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
historyStartSeq={1}
hasOlderRecords
onLoadOlder={onLoadOlder}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
let scrollHeight = 200
Object.defineProperties(tablePane, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => scrollHeight },
})
tablePane.scrollTop = 0
fireEvent.scroll(tablePane)
fireEvent.scroll(tablePane)
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
resolveOlder?.(true)
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
scrollHeight = 260
view.rerender(
<TrajectoryTable
turns={[{
turn: 0,
groups: [{
title: 'Step 1',
cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
}],
}, ...TURNS]}
{...FOLD_PROPS}
historyStartSeq={0}
onLoadOlder={onLoadOlder}
/>,
)
expect(tablePane.scrollTop).toBe(60)
})
it('covers the ledger while the initial tail is loading', () => {
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
)
expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
})
it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />,
)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
})
})
it('mounts only the visible window for a long ledger', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
const scrollTo = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
})
const cells = Array.from({ length: 500 }, (_, index) => ({
index: index + 1,
kind: 'context' as const,
text: `Context ${index + 1}`,
timeSeconds: 0,
}))
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{ title: 'Context', cells }],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
await waitFor(() => {
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeGreaterThan(0)
})
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeLessThan(cells.length)
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500')
expect(view.container.querySelector('tr[data-trajectory-row-key]')
?.getAttribute('aria-rowindex')).toBe('1')
expect(scrollTo).toHaveBeenCalled()
expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
expect(screen.getByText('Context 1')).toBeTruthy()
expect(screen.queryByText('Context 500')).toBeNull()
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 9_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(Number(view.container.querySelector(
'tr[data-virtual-position]',
)?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
})
expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
expect(screen.queryByText('Context 1')).toBeNull()
})
it('does not re-scroll a virtual ledger when streaming only changes row content', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
const scrollTo = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
})
const cells = Array.from({ length: 500 }, (_, index) => ({
index: index + 1,
kind: 'context' as const,
sourceSeq: index + 1,
text: `Context ${index + 1}`,
timeSeconds: 0,
}))
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{ title: 'Context', cells }],
}]
const view = render(
<TrajectoryTable
turns={turns}
{...FOLD_PROPS}
/>,
)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
})
scrollTo.mockClear()
view.rerender(
<TrajectoryTable
turns={turns}
streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]}
{...FOLD_PROPS}
/>,
)
expect(scrollTo).not.toHaveBeenCalled()
expect(screen.getByText('Context 1 streaming update')).toBeTruthy()
})
it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
const turns: readonly TrajectoryTurnModel[] = Array.from(
{ length: 101 },
(_, index) => ({
turn: index + 1,
groups: [{
title: 'Step 1',
cells: [
{
index: index * 2 + 1,
kind: 'message' as const,
sourceSeq: index * 2 + 1,
text: `Message ${index + 1}`,
timeSeconds: 1,
},
{
index: index * 2 + 2,
kind: 'tool' as const,
callId: `call-${index + 1}`,
text: `Tool ${index + 1}`,
timeSeconds: 1,
},
],
}],
}),
)
const collapsedTurns = new Set(turns.flatMap(turn =>
turn.turn === null ? [] : [turn.turn]))
const view = render(
<TrajectoryTable
turns={turns}
{...FOLD_PROPS}
collapsedTurns={collapsedTurns}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 5_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
})
})
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()

View File

@@ -73,6 +73,7 @@ function historySnapshot(
state: 'ready',
error: null,
hasMore: false,
baseSeq: nodes[0]?.seq ?? 0,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
@@ -89,11 +90,15 @@ function historySnapshot(
function standaloneHistory(
snapshot: SessionHistorySnapshot,
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
): Pick<
ComponentProps<typeof TrajectoryView>,
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadAllHistory: () => Promise.resolve(),
loadHistoryTail: () => Promise.resolve(),
loadOlderHistory: () => Promise.resolve(false),
}
}
@@ -145,13 +150,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
loadTail: loadHistoryTail,
loadOlder: loadOlderHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
@@ -167,7 +174,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadAllHistory }
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -201,7 +208,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
loadAllHistory: trajectory.loadAllHistory,
loadHistoryTail: trajectory.loadHistoryTail,
loadOlderHistory: trajectory.loadOlderHistory,
setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
@@ -295,9 +303,9 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
@@ -572,14 +580,53 @@ describe('timeline projection', () => {
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s')
expect(tooltip?.textContent).toContain('Total 2,000 ms')
expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s')
expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
} finally {
vi.useRealTimers()
}
})
it('marks an unloaded history prefix without inventing timeline duration', () => {
const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
const view = render(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
hasEarlierRecords
onLoadEarlier={onLoadEarlier}
onRangeChange={vi.fn()}
/>,
)
const boundary = screen.getByLabelText('Load earlier history')
expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
fireEvent.pointerEnter(boundary)
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
fireEvent.focus(boundary)
expect(screen.getByRole('tooltip').textContent)
.toContain('Click to load earlier history')
fireEvent.click(boundary)
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Loading earlier history')).toBeTruthy()
view.rerender(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
})
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
@@ -614,7 +661,35 @@ describe('timeline projection', () => {
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('clamp(0.25px, 0.8%, 1px)')
.toBe('min(0.8%, 1px)')
})
it('keeps dense sequence spans proportional before applying the pixel floor', () => {
const denseTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 400 }, (_, index) => ({
index,
kind: 'message' as const,
text: `message ${index}`,
timeSeconds: 1,
})),
}],
}]
const view = render(
<TrajectoryTimeline
turns={denseTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('min(0.02%, 1px)')
})
it('clears the selection without changing zoom on a zoomed right click', () => {
@@ -624,15 +699,18 @@ describe('timeline projection', () => {
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
hasEarlierRecords
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
expect(domainWidth).not.toBe('100%')
@@ -1065,7 +1143,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
@@ -1075,6 +1154,74 @@ describe('TrajectoryView branches', () => {
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
})
it('does not remount the ledger when prepending shifts a rewind generation id', () => {
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'stable rewind response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const snapshot = (id: number) => historySnapshot([current], {
contexts: [{
id,
origin: 'rewind' as const,
originSeq: 4,
nodes: [current],
}],
})
const store = createSnapshotStore(snapshot(1))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
const row = screen.getByRole('row', { name: /stable rewind response/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
act(() => { store.set(snapshot(2)) })
expect(screen.getByRole('row', { name: /stable rewind response/ })
.getAttribute('aria-selected')).toBe('true')
})
it('keeps ledger and timeline selection on the same event after prepend', () => {
const older = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'older prompt' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'selected current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot([current]))
const view = render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
act(() => { store.set(historySnapshot([older, current])) })
const row = screen.getByRole('row', { name: /selected current response/ })
expect(row.getAttribute('aria-selected')).toBe('true')
const currentIndex = row.getAttribute('data-record-index')
expect(view.container.querySelector(
`[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
)).toBeTruthy()
})
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,
@@ -1108,7 +1255,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)

View File

@@ -0,0 +1,95 @@
/** Measurable virtual-row grouping and durable identity contracts. */
import { describe, expect, it } from 'vitest'
import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
type VirtualizableTrajectoryRecord,
} from '../src/client/trajectory-virtual-rows.ts'
function record(
index: number,
cell: Partial<TrajectoryCellProps> = {},
collapsedSummaryKind?: 'turn' | 'assistant',
): VirtualizableTrajectoryRecord {
return {
cell: {
index,
kind: 'message',
text: `record ${index}`,
timeSeconds: 0,
...cell,
},
...(collapsedSummaryKind === undefined ? {} : { collapsedSummaryKind }),
}
}
describe('trajectory virtual rows', () => {
it('groups zero-height request boundaries with the following content row', () => {
const first = record(1, { requestOnly: true, sourceSeq: 10 })
const second = record(2, { requestOnly: true, sourceSeq: 11 })
const content = record(3, { sourceSeq: 12 })
expect(groupTrajectoryVirtualRows([first, second, content])).toEqual([{
entries: [
{ logicalIndex: 0, record: first },
{ logicalIndex: 1, record: second },
{ logicalIndex: 2, record: content },
],
height: 30,
key: trajectoryVirtualRecordKey(content),
}])
})
it('retains terminal request-boundary clearance as a measurable row', () => {
const content = record(1, { sourceSeq: 10 })
const boundary = record(2, { requestOnly: true, sourceSeq: 11 })
const rows = groupTrajectoryVirtualRows([content, boundary])
expect(rows).toHaveLength(2)
expect(rows[1]).toEqual({
entries: [{ logicalIndex: 1, record: boundary }],
height: 9,
key: trajectoryVirtualRecordKey(boundary),
})
})
it('uses the rendered collapsed-summary height', () => {
const summary = record(1, { sourceSeq: 10 }, 'turn')
expect(groupTrajectoryVirtualRows([summary])[0]?.height).toBe(20)
})
it('keeps an existing row key stable when older history is prepended', () => {
const existing = record(2, { sourceSeq: 100 })
const prepended = record(1, { sourceSeq: 10 })
const before = groupTrajectoryVirtualRows([existing])[0]?.key
const after = groupTrajectoryVirtualRows([prepended, existing])[1]?.key
expect(after).toBe(before)
})
it('keeps the content key when a request boundary joins its row', () => {
const content = record(2, { sourceSeq: 100 })
const boundary = record(1, { requestOnly: true, sourceSeq: 99 })
expect(groupTrajectoryVirtualRows([boundary, content])[0]?.key)
.toBe(groupTrajectoryVirtualRows([content])[0]?.key)
})
it('distinguishes a folded summary from its source record', () => {
const source = record(1, { sourceSeq: 10 })
const summary = record(1, { sourceSeq: 10 }, 'assistant')
expect(trajectoryVirtualRecordKey(summary)).not.toBe(trajectoryVirtualRecordKey(source))
})
it('exposes a DOM-safe semantic key', () => {
const source = record(1, { callId: 'call with spaces/and?punctuation' })
expect(trajectoryVirtualRecordKey(source)).toBe(
'message%00call%00call%20with%20spaces%2Fand%3Fpunctuation',
)
})
})