Merge remote-tracking branch 'origin/master' into feat/web-terminal-card

# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-28 21:03:26 +08:00
407 changed files with 6332 additions and 3102 deletions

View File

@@ -5,8 +5,24 @@
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
import {
createAssistantMessage,
createToolResultMessage,
createUserMessage,
} from '@deepseek-ai/dsh-llm/message'
import { CallId } from '@deepseek-ai/dsh-llm/brand'
import type {
AssistantMessage,
ContentBlock,
MessageSource,
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type {
SessionEvent,
SessionId,
TodoItem,
} from '@deepseek-ai/dsh-session/types'
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
@@ -27,6 +43,21 @@ function text(t: string): ContentBlock[] {
return [{ type: 'text', text: t }]
}
function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'user' }): UserMessage {
return createUserMessage({ content, source })
}
function assistantMessage(content: ContentBlock[]): AssistantMessage {
return createAssistantMessage({
content,
source: { provider: 'fixture', model: 'fx-1' },
})
}
function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage {
return createToolResultMessage({ callId: CallId(callId), content, isError })
}
const MARKDOWN_FIXTURE = [
'# Markdown fixture',
'',
@@ -131,10 +162,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
const userSeq = push({
type: 'user/message', surfaceOp: 'append',
data: {
content: text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`),
source: { kind: 'user' },
},
data: userMessage(text(turn === 59 ? USER_MARKDOWN_LITERAL : `问题 ${turn}fixture 历史消息,用于翻页与渲染验收。`)),
})
if (turn === 0) {
push({
@@ -143,7 +171,7 @@ function buildAlphaLog(): SessionEvent[] {
})
}
if (turn % 9 === 4) {
push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入turn ${turn}`), source: { kind: 'plugin', plugin: 'fixture' } } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`[fixture] 上下文注入turn ${turn}`), { kind: 'plugin', plugin: 'fixture' }) })
}
push({ type: 'step/start', data: { turn, step: 0 } })
const withTool = turn % 5 === 2
@@ -154,19 +182,19 @@ function buildAlphaLog(): SessionEvent[] {
if (withTool) {
const callId = `fx-call-${turn}`
blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, message: assistantMessage(blocks) } })
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(`ECHO: TURN ${turn}`), turn % 25 === 12) } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'step/start', data: { turn, step: 1 } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, message: assistantMessage(text(`工具结果已消化turn ${turn})。`)) } })
push({ type: 'step/end', data: { turn, step: 1 } })
} else {
push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
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, content: text(`插话 ${turn}fixture steering 消息。`), source: { kind: 'user' } } })
push({ type: 'steering/message', surfaceOp: 'append', data: { turn, message: userMessage(text(`插话 ${turn}fixture steering 消息。`)) } })
}
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
@@ -177,14 +205,14 @@ function buildAlphaLog(): SessionEvent[] {
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: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}${name} 样本。`), source: { kind: 'user' } } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}${name} 样本。`)) })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
@@ -207,11 +235,11 @@ function buildAlphaLog(): SessionEvent[] {
+ '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: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}run_code 样本。`), source: { kind: 'user' } } })
push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}run_code 样本。`)) })
push({ type: 'step/start', data: { turn, step: 0 } })
push({
type: 'assistant/message', surfaceOp: 'append',
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock]) },
})
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
@@ -232,7 +260,7 @@ function buildAlphaLog(): SessionEvent[] {
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
})
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
@@ -329,13 +357,13 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const callId = String(event.data.callId)
const callId = String(event.data.message.source.callId)
for (let i = log.length - 1; i >= 0; i--) {
const candidate = log[i]
/* v8 ignore next -- dense-array guard: i stays within [0, log.length),
so the undefined arm needs a sparse log no code path builds. */
if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
return view === undefined ? undefined : { for: 'result', view }
}
@@ -352,20 +380,31 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
if (titleEvent !== undefined) {
values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title
}
const todos = backscanTodos(log)
if (todos !== undefined) values['todos'] = todos
// Always present (tool-todo unit composed): null when no plan stands.
values['todos'] = backscanTodos(log) ?? null
return values
}
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
const type = (event as { type: string }).type
const key = type === 'session/title' ? 'title' : type === 'todo/write' ? 'todos' : undefined
if (key === undefined) return []
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing event is in the log, so its key always has a value. */
if (!Object.hasOwn(values, key)) return []
return [{ type: 'session/projection', sessionId: id, key, value: values[key], seq: event.seq }]
if (type === 'session/title') {
const values = projectionValuesOf(log)
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
if (!Object.hasOwn(values, 'title')) return []
return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }]
}
// Standing-plan fold: writes replace the list; turn/start clears it (null).
if (type === 'todo/write' || type === 'turn/start') {
return [{
type: 'session/projection',
sessionId: id,
key: 'todos',
value: backscanTodos(log) ?? null,
seq: event.seq,
}]
}
return []
}
/**
@@ -399,11 +438,16 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
*/
function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = log.length - 1; i >= 0; i--) {
const event = log[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
if (event === undefined) continue
if (event.type === 'turn/start') return undefined
if (event.type === 'todo/write') return event.data.todos
}
return undefined
}
@@ -623,7 +667,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
/** Log append + mux emit (the normal live path). */
appendUser(id: string, msg: string): void {
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) })
},
/** Append a later durable title revision through the normal raw-event + control-frame path. */
appendTitle(id: string, title: string): void {
@@ -634,7 +678,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent)
},
/** End every open stream generator (client sees both streams close -> reconnect + resync path). */
breakStreams(): void {
@@ -655,7 +699,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
replays.delete(id)
const done = pieces.slice(0, i).join('')
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
append(id, { type: 'step/end', data: { turn, step } })
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
setRunning(id, false)
@@ -824,14 +868,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// 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, content, source: { kind: 'user' } } })
append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, message: 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' } } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
startReply(
id,
turn,

View File

@@ -69,7 +69,10 @@ describe('createFixtureApi', () => {
// tail block still rides it — empty-log cut at -1, the host convention.
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false, projections: { asOfSeq: -1, values: {} } })
// Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write.
expect(empty.result.value).toEqual({
events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } },
})
})
it('serves grouped models and keeps a selected target for later history and fixture requests', 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: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`
## Workspace 与 Session 列表

View File

@@ -57,21 +57,23 @@ function materializeNode(
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: String(event.data.callId),
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: event.data.content, isError: event.data.isError,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,

View File

@@ -360,13 +360,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'session/queued': {
const message = frame.message
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(frame.content) },
row: { key, preview: queuePreviewOf(message.content) },
steering: frame.steering,
sourceJson: JSON.stringify(frame.source),
sourceJson: JSON.stringify(message.source),
})
this.queueRev++
this.notifier.markDirty()
@@ -603,7 +604,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (event.data.trigger.kind !== 'message') return
index = this.queued.findIndex(entry => !entry.steering)
} else if (event.type === 'steering/message') {
const source = JSON.stringify(event.data.source)
const source = JSON.stringify(event.data.message.source)
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
} else {
return
@@ -701,7 +702,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
@@ -13,7 +14,9 @@ export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: text(body), source: { kind: 'user' },
}) }),
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
@@ -21,11 +24,33 @@ export const ev = {
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
turn, step,
message: createMessage({
role: 'assistant',
content: text(body),
source: {
kind: 'model',
...{ provider: 'fake', model: 'fk-1' },
},
}),
} }),
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
at(seq, {
type: 'tool/result',
surfaceOp: 'append',
data: {
turn,
step,
message: createToolResultMessage({
callId: CallId(callId),
content: text(body),
isError: false,
}),
},
}),
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',

View File

@@ -1,3 +1,4 @@
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
@@ -39,8 +40,16 @@ describe('FoldAdapter', () => {
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'user/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
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({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
@@ -76,7 +85,17 @@ describe('FoldAdapter', () => {
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: {
kind: 'model',
...{ provider: 'x', model: 'y' },
},
}),
} }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
@@ -98,7 +117,15 @@ describe('FoldAdapter', () => {
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: true,
}),
error: { name: 'Boom', code: 'boom' },
} }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
@@ -203,7 +230,15 @@ describe('FoldAdapter', () => {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0,
step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: { kind: 'model', provider: 'x', model: 'y' },
}),
} }),
], 0)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(true)

View File

@@ -5,6 +5,7 @@
* pre-instantiation buffering, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
@@ -19,8 +20,12 @@ const rid = (id: string): RpcId => id as RpcId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
return {
type: 'session/queued', sessionId: SID, content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
}),
steering,
}
}
@@ -40,9 +45,12 @@ describe('queue intake', () => {
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued', sessionId: SID,
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
}),
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
@@ -93,14 +101,26 @@ describe('queue retirement (host queuedMirror rules)', () => {
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } },
data: {
turn: 0,
message: createUserMessage({
content: text('loop'),
source: { kind: 'plugin', plugin: 'loop' },
}),
},
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } },
data: {
turn: 0,
message: createUserMessage({
content: text('插话'),
source: { kind: 'user', rpcId: rid('p-2') },
}),
},
} as never
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
@@ -154,7 +174,13 @@ describe('queue reconnect semantics', () => {
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } },
data: {
turn: 1,
message: createUserMessage({
content: text('重连插话'),
source: { kind: 'user', rpcId: rid('p-steer') },
}),
},
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])

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: 2d7c26f829678c18e76d185f1a59e40cc4775682
README.zh.md: 095f0ef069f042682b308f46fbe8adb9f31f01d7
README.md: 2adce1b0389013faa452848104256cd03b141b8c
README.zh.md: 82f95a6a55427c80be26439a6658162d1b14d00b

View File

@@ -14,7 +14,7 @@ A tool call declaring the `terminal` render intent renders its command output in
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -14,7 +14,7 @@
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上是计划条`useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。

View File

@@ -38,7 +38,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
summary={model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
terminal={terminalCardModel(block)}
terminal={terminalCardModel(block, cwd)}
state={model.state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}

View File

@@ -9,7 +9,7 @@
* @module
*/
import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
/**
* Output lines the chat row's expanded terminal body shows before collapsing
@@ -33,6 +33,24 @@ export type TerminalCardModel = Pick<
'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'
>
/**
* Resolve a terminal view's working directory the way the render-intent
* contract assigns to the UI bridge: an absolute path is used as-is, a relative
* one joins under the session workspace, and an omitted one IS the session
* workspace. A pure presenter cannot see the session cwd, which is why this
* resolution belongs here rather than in the tool. Without a session cwd there
* is nothing to resolve against, so a relative path stays as authored and an
* omitted one stays absent (the prompt row then draws a bare `$`).
* @param viewCwd - the cwd the terminal call view carries, if any.
* @param sessionCwd - the session workspace root, if the caller knows it.
* @returns the working directory for the prompt label, or undefined.
*/
function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
if (viewCwd === undefined || viewCwd === '') return sessionCwd
if (sessionCwd === undefined || sessionCwd === '') return viewCwd
return resolveToolPath(sessionCwd, viewCwd)
}
/**
* Derive the terminal-card props for a tool call, or null when this call is
* not a terminal card and belongs on the generic path.
@@ -55,15 +73,17 @@ export type TerminalCardModel = Pick<
* result view's replacement title, then to an empty command (the prompt line
* draws bare), and the prompt shows no cwd.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root, which resolves an omitted or
* relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
* @returns the terminal-card props, or null for the generic path.
*/
export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | null {
export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
const call = block.callView?.card === 'terminal' ? block.callView : null
if (!('kind' in block)) {
// Running: the call view exists, the result view does not yet.
return call === null ? null : {
command: call.title,
cwd: call.cwd,
cwd: resolveTerminalCwd(call.cwd, sessionCwd),
output: undefined,
exitCode: undefined,
signal: undefined,
@@ -73,8 +93,11 @@ export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | nul
const result = block.resultView?.card === 'terminal' ? block.resultView : null
if (result === null) return null
return {
command: call?.title ?? result.title ?? '',
cwd: call?.cwd,
// The result's title REPLACES the pending one when the tool supplies it
// (the presentation contract's replacement-title rule); the call title is
// what a result without one keeps.
command: result.title ?? call?.title ?? '',
cwd: resolveTerminalCwd(call?.cwd, sessionCwd),
output: result.output,
exitCode: result.exitCode,
signal: result.signal,

View File

@@ -68,8 +68,11 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
// Session workspace root: an omitted or relative terminal cwd resolves
// against it, which the pure presenter cannot see.
const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
@@ -107,7 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
)}
<section className={css.section}>
<div className={css.sectionLabel}>Output</div>
<OutputBody material={material} />
{/* Keyed by the selected call: the body owns per-call view
state (the terminal card's expand and copy), which React
would otherwise carry into the next selection because the
panel does not unmount between calls. */}
<OutputBody key={callId} material={material} cwd={sessionCwd} />
</section>
</>
)}
@@ -123,10 +130,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
* its alignment and scrolls sideways instead of folding. Every other call, and
* a running call with no terminal card yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @returns the Output section's body element.
*/
function OutputBody({ material }: { material: CallMaterial }) {
const terminal = terminalCardModel(material.block)
function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
const terminal = terminalCardModel(material.block, cwd)
if (terminal !== null) return <TerminalBlock {...terminal} className={css.terminal} />
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.

View File

@@ -1,9 +1,9 @@
// TodoPanel: persistent plan strip above the composer (the web counterpart
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot
// no data of its own, hidden while the list is empty. Mounted through the
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
// the selecting, so the panel takes the plain list and stays framework-free.
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'

View File

@@ -45,7 +45,10 @@ function stateStatus(state: ToolRowState): string | null {
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const terminal = terminalCardModel(block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
return (

View File

@@ -55,7 +55,11 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
kind: 'user',
seq,
time: seq * 1000,
content: [{ type: 'text', text }] as never,
source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],

View File

@@ -81,6 +81,38 @@ describe('terminalCardModel', () => {
}))?.signal).toBe('SIGTERM')
})
it('takes the result view\'s replacement title over the pending one', () => {
// The presentation contract defines a result title as REPLACING the pending
// title, so a tool that rewrites it at settle time must win here.
expect(terminalCardModel(settled({
callView: callTerminal({ title: 'pnpm run check' }),
resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
}))?.command).toBe('pnpm run check --filter web')
// Without one, the call's title is what the card keeps.
expect(terminalCardModel(settled())?.command).toBe('ls -la')
})
it('resolves the cwd against the session workspace the way the bridge must', () => {
// Omitted workdir — the common bash call — IS the session workspace.
expect(terminalCardModel(settled(), '/w/app')?.cwd).toBe('/w/app')
// A relative workdir joins under it.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}), '/w/app')?.cwd).toBe('/w/app/packages/ui')
// An absolute one is used as-is.
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: '/srv/other' }),
}), '/w/app')?.cwd).toBe('/srv/other')
// With no session cwd there is nothing to resolve against: a relative path
// stays as authored and an omitted one stays absent (a bare `$` prompt).
expect(terminalCardModel(settled({
callView: callTerminal({ cwd: 'packages/ui' }),
}))?.cwd).toBe('packages/ui')
expect(terminalCardModel(settled())?.cwd).toBeUndefined()
// The running arm resolves identically.
expect(terminalCardModel(running(), '/w/app')?.cwd).toBe('/w/app')
})
it('a window-truncated call side falls back to the result title, then to an empty command', () => {
// Truncation drops both the call head and its view (conversation.ts).
const truncated = { call: null, callView: null }
@@ -223,12 +255,18 @@ describe('BashRow terminal card', () => {
})
describe('DetailsPanel Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
@@ -260,6 +298,31 @@ describe('DetailsPanel Output section', () => {
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
// The panel never unmounts between selections, so per-call view state has to
// be keyed off the selected call or it leaks into the next one.
it('resets the card\'s expand state when the selected call changes', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({
nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
}), target)
fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
// A second call, selected without unmounting the panel, starts collapsed.
cleanup()
const second = mount(snapshot({
nodes: [settled({
callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
})],
}), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
})
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
// No workdir in the call view: the prompt label is the workspace basename.
expect(view.getByText('app')).toBeTruthy()
})
it('renders the terminal card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
const view = mount(snapshot({

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: 9e5384f84c3714d327b7b4ceaba8fb0a2cd67e7b
README.zh.md: 9f2a362e4a1e03bffde1d7b218bf94ed41ffd168
README.md: 5d71aa920707462f953ed4eb5572b0530b8d0ed2
README.zh.md: 7c59ed3d3bacbac06a0123e6ff93023a1bcbd028

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Terminal output
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage-return redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
## Model Experience

View File

@@ -10,7 +10,7 @@
## 终端输出
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之退出状态属于整次调用因此每行一枚就会声称一个视图并不携带的逐行结果。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot``aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义的控制符;基础 16 色前景色映射到 `--dsw-*` token而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
## 模型体验

View File

@@ -75,12 +75,15 @@
color: var(--dsw-alias-label-tertiary);
}
/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
`nowrap` collapses the repeated spaces, tabs, and alignment of an indented
continuation. Both hold the single row and the ellipsis. */
.command {
min-width: 0;
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: pre;
}
.status {

View File

@@ -80,8 +80,12 @@ const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
/** Escape sequences other than CSI: charset selection, single-shift, reset. */
const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
/** C0 controls with no display meaning here; tab, newline and ESC survive for layout and anser's CSI split. */
const INERT_CONTROL = /[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* C0 controls with no display meaning here. Tab, newline, backspace and ESC
* survive: the first two for layout, backspace for its overwrite, ESC for
* anser's CSI split.
*/
const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
/**
* Apply carriage-return redraws: within a line, only the text after the last
@@ -98,15 +102,40 @@ function applyCarriageReturns(text: string): string {
}).join('\n')
}
/**
* Apply backspaces as the cursor-left-then-overwrite a terminal performs, so
* `abc` followed by two backspaces and `XY` reads `aXY` instead of keeping the
* characters it overwrote. Progress meters and captured PTY output use
* backspace this way. Resolved per line, so a backspace neither eats the
* newline before it nor reaches into the previous line's tail; one at a line
* start has nothing to erase.
* @param text - output text, already reduced to its carriage-return redraws.
* @returns the text with each backspace resolved against the character before it.
*/
function applyBackspaces(text: string): string {
if (!text.includes('\u0008')) return text
return text.split('\n').map((line) => {
const kept: string[] = []
for (const char of line) {
if (char === '\u0008') kept.pop()
else kept.push(char)
}
return kept.join('')
}).join('\n')
}
/**
* Remove every escape sequence and control character that carries no color,
* leaving CSI sequences for anser and `\n`/`\t` for layout.
* leaving CSI sequences for anser and `\n`/`\t` for layout. Carriage-return
* redraws and backspace overwrites resolve first: both are cursor movements
* whose effect on the visible text must land before the characters that
* expressed them are dropped.
* @param text - raw command output.
* @returns text whose only remaining escapes are CSI sequences.
*/
function sanitize(text: string): string {
const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
return applyCarriageReturns(escaped).replace(INERT_CONTROL, '')
return applyBackspaces(applyCarriageReturns(escaped)).replace(INERT_CONTROL, '')
}
/**

View File

@@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'
import { parseAnsiLines } from '../src/ansi.ts'
const ESC = '\u001b'
const BS = '\u0008'
/** Paint `text` with the SGR `codes`, then reset. */
function sgr(codes: string, text: string): string {
@@ -170,6 +171,30 @@ describe('parseAnsiLines: carriage returns', () => {
})
})
describe('parseAnsiLines: backspaces', () => {
it('applies a backspace as the overwrite a terminal draws', () => {
// `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
})
it('stops at the line start instead of eating the newline before it', () => {
expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
[{ text: 'ab', style: undefined }],
[{ text: 'cd', style: undefined }],
])
})
it('applies the overwrite after a carriage-return redraw, not before', () => {
// The redraw wins first; the backspace then erases inside what survived.
expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })
})
it('keeps the run\'s style while erasing its own characters', () => {
expect(onlySpan(sgr('31', `bad${BS}${BS}${BS}ok`)))
.toEqual({ text: 'ok', style: { color: 'var(--dsw-alias-state-error-primary)' } })
})
})
describe('parseAnsiLines: runs spanning lines', () => {
it('carries one run\'s style onto every line it covers', () => {
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([

View File

@@ -217,6 +217,18 @@ describe('TerminalBlock run-state dot', () => {
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
})
// A heredoc or an editor-authored command commonly ends in a newline; that
// terminator is not a further, empty command to draw a row for.
it('drops a trailing newline instead of drawing an empty final row', () => {
const view = render(<TerminalBlock command={'echo one\necho two\n'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
})
it('keeps a genuinely blank command line when the command ends with two newlines', () => {
const view = render(<TerminalBlock command={'echo one\n\n'} output="a" exitCode={0} />)
expect(promptRows(view.container)).toEqual(['$echo one', '$'])
})
// The exit status the view carries is the whole call's — bash reports no
// per-command status — so exactly one dot and one label are correct however
// many lines the command spans. A dot per row would assert, of a line that