Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

# Conflicts:
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 19:02:29 +08:00
188 changed files with 5000 additions and 660 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 82f1bc95a6128245f88f01a0de0849494cb98359
README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de
README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0
README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a

View File

@@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

View File

@@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。

View File

@@ -331,6 +331,8 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** In-window completed turn number -> its `turn/end` event seq. */
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**

View File

@@ -113,6 +113,11 @@ export class Session implements SessionFace {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Completed turn boundaries retained from the raw window so presentation
* actions never infer a safe fork point from transcript content alone. */
private turnEnds = new Map<number, number>()
private turnEndsRev = 0
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
@@ -821,6 +826,8 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
@@ -911,6 +918,8 @@ export class Session implements SessionFace {
this.callsRev++
this.derivedNodes = []
this.derivedRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
@@ -942,6 +951,9 @@ export class Session implements SessionFace {
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
}
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
@@ -955,6 +967,7 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,

View File

@@ -426,6 +426,7 @@ describe('live event path', () => {
feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.turnEnds.get(1)).toBe(10)
const frozen = snapshot.nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
// Ordered inside the flow: after the user message (seq 7), before any later turn.
@@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.turnEnds).toBe(before.turnEnds)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()

View File

@@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
return {
sessionId,
nodes: [],
turnEnds: new Map(),
partial: null,
runningCalls: [],
codeDispatches: new Map(),

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: 728d67aaccee609d5f28ee8744c1f11ca74b040a
README.zh.md: eec1d36b9758dddcf63eb75c3a74c51b4afb0a72
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138

View File

@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
@@ -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, restores Copy and Fork from the durable node, 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; 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.
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.
@@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request.
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

View File

@@ -14,7 +14,7 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
@@ -32,13 +32,13 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
工具行同样是 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 已声明bash 示例是第三方姿态的范例。Trajectory/waterfall瀑布式事件工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`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` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/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 轮次,不显示失败,也不会丢失草稿事务。
@@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
- **统计行的耗时只覆盖窗口内消息流**LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**以原始形式显示已选择调用的参数结果Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题打开子会话,而 fork 或改名失败时源会话保持选中。
- **已发送的 user 消息无法编辑**user 气泡的 IconActions 行只有时钟复制分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到轮次末尾,在 client 端递增继承标题打开子会话fork 或改名失败时源会话保持选中[决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)
- **已发送的 user 消息无法编辑**user 气泡保留时钟复制分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**figma 条没有换行或展开入口,完整文本无法在行内读完。

View File

@@ -4,9 +4,10 @@
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
// Finalized turn-tail content (text) nodes append IconActions once streaming
// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
// nodes stay chrome-free.
// Finalized content (text) nodes append IconActions once streaming ends
// (`time` is omitted for mid-turn narration); their branch action is enabled
// only when the node is also the completed turn's transcript tail. Think /
// tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -28,8 +29,10 @@ export interface AssistantMarkdownProps {
time?: number | undefined
/** Event sequence used as the fork boundary; omitted while streaming. */
seq?: number | undefined
/** Fork the session through the turn containing this finalized message. */
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -76,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, seq, onFork, t,
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -120,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
time={time}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -236,6 +236,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnEnds = useSession(s => s.turnEnds)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -257,6 +258,7 @@ export function ChatView({
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -413,6 +415,7 @@ export function ChatView({
time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)
@@ -428,6 +431,7 @@ export function ChatView({
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
t={t}
/>
)

View File

@@ -43,3 +43,23 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Unavailable stays focusable and hoverable so Tooltip can explain why. */
.action[data-unavailable] {
cursor: default;
opacity: 0.4;
}
.action[data-unavailable]:hover {
background: transparent;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,7 +1,7 @@
// Shared IconActions chrome for user, steering, and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback } from 'react'
import { useCallback, useId } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -17,9 +17,11 @@ export interface MessageIconActionsProps {
time?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message. */
/** Fork the session at this message; omission hides the branch action. */
onBranch?: (() => void) | undefined
/** Whether to render the branch action; defaults to true. */
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
branchUnavailable?: boolean | undefined
/** Additional branch visibility gate for transient message chrome; defaults to true. */
showBranch?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
@@ -33,9 +35,10 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, clock, onBranch, showBranch = true, className, t,
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
@@ -52,13 +55,25 @@ export function MessageIconActions({
<IconCopyOutline16 />
</button>
</Tooltip>
{showBranch && (
<Tooltip label={t('message.branch')} side="bottom">
<button type="button" className={css.action} aria-label={t('message.branch')} onClick={onBranch}>
{showBranch && onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
<button
type="button"
className={css.action}
aria-label={t('message.branch')}
aria-disabled={branchUnavailable || undefined}
aria-describedby={branchUnavailable ? reasonId : undefined}
data-unavailable={branchUnavailable || undefined}
onClick={branchUnavailable ? undefined : onBranch}
>
<IconBranchOutline16 />
</button>
</Tooltip>
)}
{showBranch && onBranch !== undefined && branchUnavailable && (
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View File

@@ -26,8 +26,10 @@ export interface MessageItemProps {
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
/** Fork through this message's completed turn when eligible. */
onFork?: (seq: number) => void
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
node, retryActive = false, onFork, forkUnavailable = false, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
@@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>

View File

@@ -5,8 +5,8 @@
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
* flow share one gate.
* and completed-turn branch points are derived here too so ChatView and the
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ToolResultNode,
@@ -47,6 +47,39 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
return new Set(lastByTurn.values())
}
/**
* Seq set of message rows that may fork: the last transcript node of a
* completed turn, when that node owns message chrome. A later tool, reasoning,
* error, or other transcript node leaves the earlier message's branch action
* unavailable because the Host would include the whole turn.
* @param nodes - snapshot nodes in event order.
* @param turnEnds - completed turn boundaries retained from the event window.
* @returns Message seq values whose visible position matches the fork boundary.
*/
export function messageBranchSeqs(
nodes: readonly ConversationNode[],
turnEnds: ReadonlyMap<number, number>,
): ReadonlySet<number> {
const result = new Set<number>()
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
let nodeIndex = 0
for (const [turn, endSeq] of boundaries) {
let tail: ConversationNode | undefined
while (nodeIndex < nodes.length) {
const candidate = nodes[nodeIndex]
if (candidate === undefined || candidate.seq > endSeq) break
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
|| (tail?.kind === 'steering' && tail.turn === turn)
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}
}
return result
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes in human-transcript and durable-notice order.

View File

@@ -461,7 +461,7 @@ export interface ChatViewInjected {
/** Last recorded offset, or null when pinned or never recorded. */
read: () => number | null
}
/** Fork the session through the turn containing the message at `seq`, then open the child. */
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
}

View File

@@ -59,6 +59,7 @@ export const zh = {
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
@@ -166,6 +167,7 @@ export const en = {
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.branchUnavailable': 'Available only on the last message of a completed turn',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',

View File

@@ -36,12 +36,14 @@ describe('MessageItem arms', () => {
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time,
content: [{ type: 'text', text: 'hello bubble' }] as never,
source: null,
}}
onFork={onFork}
/>,
)
expect(screen.getByText('14:24')).toBeTruthy()
@@ -50,6 +52,8 @@ describe('MessageItem arms', () => {
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(1)
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
@@ -74,6 +78,30 @@ describe('MessageItem arms', () => {
expect(exec).toHaveBeenCalledWith('copy')
})
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
const onFork = vi.fn()
render(
<MessageItem t={t} node={{
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'open turn' }] as never,
source: null,
}}
onFork={onFork}
forkUnavailable
/>,
)
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
expect(branch.disabled).toBe(false)
expect(branch.getAttribute('aria-disabled')).toBe('true')
const reasonId = branch.getAttribute('aria-describedby')
expect(reasonId).not.toBeNull()
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
fireEvent.click(branch)
expect(onFork).not.toHaveBeenCalled()
fireEvent.focus(branch)
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -420,12 +448,15 @@ describe('small branch tails', () => {
})
const now = new Date()
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
const onFork = vi.fn()
const settled = render(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
seq={3}
onFork={onFork}
/>,
)
expect(settled.getByText('14:24')).toBeTruthy()
@@ -433,6 +464,8 @@ describe('small branch tails', () => {
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
fireEvent.click(settled.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('answer body')
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
expect(onFork).toHaveBeenCalledWith(3)
settled.unmount()
const thinkOnly = render(

View File

@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -211,6 +211,29 @@ describe('chat-flow derivation', () => {
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const nodes: ConversationNode[] = [
user(1, 'first'),
assistant(2, 'answer before tools'),
toolResult(3, 'a'),
interruptedThink,
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,
},
]
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
expect([...seqs]).toEqual([7, 10, 13])
})
})
describe('ChatView', () => {
@@ -302,8 +325,18 @@ describe('ChatView', () => {
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
fireEvent.click(unavailable)
expect(h.forkAt).not.toHaveBeenCalled()
act(() => {
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
})
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(2)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(branchButtons[1]!)
expect(h.forkAt).toHaveBeenCalledWith(2)
})
@@ -404,21 +437,47 @@ describe('ChatView', () => {
user(5, 'next'),
assistant(6, 'second turn', 2),
],
turnEnds: new Map([[1, 4], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
// Every message footer keeps branch visible; only completed assistant tails enable it.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(branchButtons).toHaveLength(4)
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
})
it('forks from both user and finalized assistant message actions at their event seq', () => {
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer')],
turnEnds: new Map([[1, 3]]),
})
const view = render(<h.ChatView {...h.props} />)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
const interruptedThink: AssistantMessageNode = {
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
}
const h = makeHarness({
nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink],
turnEnds: new Map([[1, 5]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
expect(buttons).toHaveLength(2)
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
fireEvent.click(buttons[0]!)
fireEvent.click(buttons[1]!)
expect(h.forkAt).not.toHaveBeenCalled()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {

View File

@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

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: 8bdc3f043488631424c85a4319020c3d8ba5437b
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20
README.md: 7318acd9b9a6047b1144789bcd2655132237f6c5
README.zh.md: e326846dc2099472bc0a81dff093ff24b614559b

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 image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`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).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`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
## 终端输出

View File

@@ -1,13 +1,12 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
// the same across front ends: the removed side is the old text in full, the
// added side the new text in full, both split on the same terminator rule, and
// the footer counts distinct paths on both ends. Output never soft-wraps — an
// aligned source line keeps its indentation and scrolls horizontally instead of
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
// changed-row comparison, this block renders the old and new sides in full.
// Both front ends share the line-terminator rule and distinct-path file count.
// Output never soft-wraps — an aligned source line keeps its indentation and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; geometry mirrors CodeBlock.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'

View File

@@ -230,3 +230,14 @@
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}
.image {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin: 0;
border-radius: 8px;
background: var(--dsw-alias-bg-base);
object-fit: contain;
}

View File

@@ -37,6 +37,15 @@ export interface MarkdownCodeLabels {
copiedLabel?: string | undefined
}
function remoteImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
return undefined
}
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
@@ -53,7 +62,20 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
return (
<img
className={css.image}
src={imageSrc}
alt={alt}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
/>
)
},
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
@@ -98,7 +120,9 @@ const streamingComponents = buildComponents(true)
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string

View File

@@ -99,13 +99,35 @@ describe('MarkdownText', () => {
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
it('renders absolute HTTP(S) images with bounded presentation', () => {
const markdown = [
'![secure diagram](https://example.com/secure.png)',
'![plain diagram](http://example.com/plain.png)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
const images = [...container.querySelectorAll('img')]
expect(images.map(image => image.getAttribute('src'))).toEqual([
'https://example.com/secure.png',
'http://example.com/plain.png',
])
for (const image of images) {
expect(image.getAttribute('loading')).toBe('lazy')
expect(image.getAttribute('decoding')).toBe('async')
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
}
})
it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
const markdown = [
'<script>globalThis.compromised = true</script>',
'<img src="x" onerror="globalThis.compromised = true">',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
'![remote diagram](https://example.com/private.png)',
'![relative diagram](private.png)',
'![absolute diagram](/workspace/private.png)',
'![file diagram](file:///workspace/private.png)',
'![script diagram](javascript:alert(1))',
'![mail diagram](mailto:dev@example.com)',
].join('\n\n')
const { container } = render(<MarkdownText text={markdown} />)
@@ -117,7 +139,11 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
expect(screen.getByText('remote diagram')).toBeTruthy()
expect(screen.getByText('relative diagram')).toBeTruthy()
expect(screen.getByText('absolute diagram')).toBeTruthy()
expect(screen.getByText('file diagram')).toBeTruthy()
expect(screen.getByText('script diagram')).toBeTruthy()
expect(screen.getByText('mail diagram')).toBeTruthy()
})
it('keeps incomplete streaming Markdown renderable', () => {

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-question/README.md
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.

View File

@@ -4,7 +4,7 @@
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定超长的详情与选项共享内部滚动区。单选选项会立即前进所有问题均已回答或跳过后Enter 会提交IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。

View File

@@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
if (question.multiSelect === true) {
const selected = current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', skipped: false }
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
@@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
selected: custom === '' || item.multiSelect === true ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
@@ -155,14 +156,17 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}
// Shared by the inline custom input and the optionless textarea: typing a
// custom draft clears any selection, and Enter continues the flow
// (Shift+Enter stays a newline in the textarea; on the single-line input it
// is inert either way).
// Shared by the inline custom input and the optionless textarea: a
// multi-select draft retains checked labels, while a single-select custom
// answer replaces its selection. Enter continues the flow (Shift+Enter
// stays a newline in the textarea; on the single-line input it is inert).
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, skipped: false,
...current,
selected: question.multiSelect === true ? current.selected : [],
custom: value,
skipped: false,
}))
}

View File

@@ -104,13 +104,19 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
const multiCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(multiCustom, { target: { value: '沟通能力' } })
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true')
expect((multiCustom as HTMLInputElement).value).toBe('沟通能力')
fireEvent.keyDown(multiCustom, { key: 'Enter' })
// The domain face encoded the whole batch into one carrier envelope.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
{ id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
]))
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
@@ -233,6 +239,11 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: 'x' },
{ id: 'signals', selected: ['系统设计'] },
]))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(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-sidebar/README.md
README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d
README.md: 19c2d1033de4475816249aa8429f4a589eeb6481
README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2

View File

@@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.

View File

@@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent真实 Work
## 已知限制与暂缓事项
- **状态点只有两种实时数据状态running/none**done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入
- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**done/error 的通知数据源仍暂缓实现
- **分组选单只提供按 Workspace 分组**Update/Status 分组策略只有图稿而没有规范,暂缓实现。
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。

View File

@@ -67,7 +67,7 @@
min-width: 0;
}
.menu > .node {
margin-left: -8px;
margin-left: -3px;
}
.row {

View File

@@ -100,17 +100,12 @@
}
.table tbody tr[data-request-only='true'] td {
height: 1px;
height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td {
/* Keep consecutive boundary markers from painting their halos over one another. */
height: 9px;
}
.table tbody tr[data-request-only='true']:last-child td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
@@ -130,10 +125,12 @@
}
.requestBoundaryControl {
--request-boundary-base-left: 12px;
position: absolute;
z-index: 6;
top: -8px;
left: 12px;
left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px));
width: 16px;
height: 16px;
padding: 0;
@@ -198,6 +195,12 @@
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
}
.requestBoundaryControl[data-request-status='error']::before,
.requestBoundaryControl[data-request-status='error']:hover::before,
.requestBoundaryControl[data-request-status='error']:focus-visible::before {
background: var(--dsw-alias-state-error-primary);
}
.requestBoundaryControl:hover::after,
.requestBoundaryControl:focus-visible::after {
opacity: 1;
@@ -402,7 +405,7 @@
}
.requestBoundaryControl {
left: 6px;
--request-boundary-base-left: 6px;
}
.kindSlot {
@@ -1245,9 +1248,19 @@
background: transparent;
cursor: pointer;
font: 600 12px/18px var(--dsw-font-family);
gap: 2px;
user-select: none;
}
.thinkingChevron {
flex: none;
transition: transform 120ms var(--ds-ease-in-out);
}
.thinkingToggle[aria-expanded='true'] .thinkingChevron {
transform: rotate(90deg);
}
.thinkingToggle:hover {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & {
'--trajectory-tool-request-width': string
}
type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -453,6 +457,22 @@ function indexRequestNumbers(
return numbers
}
function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap<number, number> {
const indexes = new Map<number, number>()
let runLength = 0
for (const record of records) {
if (record.cell.requestOnly === true) {
indexes.set(record.cell.index, runLength++)
continue
}
if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) {
indexes.set(record.cell.index, runLength)
}
runLength = 0
}
return indexes
}
function summarizeTurn(records: readonly TableRecord[]): string {
const steps = new Set(
records
@@ -1212,7 +1232,8 @@ function MarkdownRecordContent({
aria-expanded={thinkingExpanded}
onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
>
{thinkingExpanded ? 'Thinking' : 'Thinking ...'}
Thinking
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
</button>
{thinkingExpanded && (
<MarkdownFragment
@@ -1545,6 +1566,7 @@ export function TrajectoryTable({
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
@@ -1791,6 +1813,12 @@ export function TrajectoryTable({
const requestInfo = request === undefined
? undefined
: sessionRequestNumbers?.find(candidate => candidate.number === request)
const requestStatus = requestInfo?.status
?? (record.cell.isError === true ? 'error' : undefined)
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
const requestBoundaryStyle: RequestBoundaryStyle = {
'--request-boundary-offset': `${requestRunIndex * 8}px`,
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
@@ -1882,6 +1910,9 @@ export function TrajectoryTable({
aria-label={requestLabel}
aria-pressed={requestSelected}
data-label={requestLabel}
data-request-run-index={requestRunIndex}
data-request-status={requestStatus}
style={requestBoundaryStyle}
onClick={(event) => {
event.stopPropagation()
selectRequest({
@@ -1930,36 +1961,36 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</Tooltip>
</span>
</span>
)}
</div>
@@ -2497,7 +2528,7 @@ export function TrajectoryTable({
)}
{selectedAssistantRequestTarget !== undefined && (
<OverviewSection
label="Timing"
label="Request Timing"
onOpen={() => {
selectRequest(selectedAssistantRequestTarget, 'timing')
}}

View File

@@ -64,7 +64,7 @@ describe('TrajectoryTable', () => {
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
expect(screen.getByText('500 ms')).toBeTruthy()
expect(screen.getByText('1.00 s')).toBeTruthy()
@@ -101,10 +101,13 @@ describe('TrajectoryTable', () => {
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
const toggle = screen.getByRole('button', { name: 'Thinking' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText(thinking)).toBeNull()
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
})
@@ -222,19 +225,79 @@ describe('TrajectoryTable', () => {
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
it('marks failed requests and lays coincident request markers left to right', () => {
const turns: readonly TrajectoryTurnModel[] = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: '',
requestOnly: true,
isError: true,
timeSeconds: 0.1,
}],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index: 2,
kind: 'message',
text: '',
requestOnly: true,
isError: true,
timeSeconds: 0.1,
}],
}],
},
{
turn: 3,
groups: [{
title: 'Step 1',
cells: [{
index: 3,
kind: 'message',
text: 'Recovered response',
timeSeconds: 0.1,
}],
}],
},
]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
const failed = screen.getByRole('button', { name: 'Request #1' })
const retry = screen.getByRole('button', { name: 'Request #2' })
const recovered = screen.getByRole('button', { name: 'Request #3' })
expect(failed.getAttribute('data-request-status')).toBe('error')
expect(failed.getAttribute('data-request-run-index')).toBe('0')
expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
expect(retry.getAttribute('data-request-run-index')).toBe('1')
expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
expect(recovered.getAttribute('data-request-run-index')).toBe('2')
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
})
it('shows the custom role tooltip only from the responsive icon', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
expect(toolIcon).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.mouseEnter(toolIcon as HTMLElement)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toBe('TOOL')
expect(tooltip.getAttribute('data-side')).toBe('right')
fireEvent.mouseLeave(toolTag as HTMLElement)
fireEvent.mouseLeave(toolIcon as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})

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-workspace/README.md
README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607
README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b
README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469
README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab

View File

@@ -12,6 +12,8 @@ Workspace and Session hover cards copy the value their row clips: activating a W
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state.
@@ -28,4 +30,5 @@ None; this package neither assembles nor sends a provider request.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions.
- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -12,6 +12,8 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开琥珀色警告点优先于运行指示器hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行供对话、标题与已寻址传输状态使用。
@@ -28,4 +30,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token短语匹配选择结果会打开 Session而不是匹配的事件。
- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -46,6 +46,7 @@ export const zh = {
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.idle': '空闲',
'status.waitingApproval': '等待审批',
'hover.created': '创建于 {time}',
'hover.copied': '已复制',
'date.ymd': '{y}年{m}月{d}日',
@@ -103,6 +104,7 @@ export const en = {
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.idle': 'Idle',
'status.waitingApproval': 'Waiting for approval',
'hover.created': 'Created {time}',
'hover.copied': 'Copied',
'date.ymd': '{y}-{m}-{d}',

View File

@@ -124,6 +124,15 @@
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.folderActive {
color: var(--dsw-alias-state-business-primary);

View File

@@ -12,6 +12,7 @@ import {
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import { relativeTime } from '../tree.ts'
@@ -165,16 +166,16 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
)
}
/**
* One top-level 34px session row with running dot and relative time.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @returns the session row.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
/** Session status presentation; approval waiting outranks the underlying running state. */
function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } {
if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') }
if (node.running) return { state: 'ongoing', label: t('status.running') }
return { state: 'done', label: t('status.idle') }
}
/** Hover-card body: full title, relative time, and approval/running/idle status. */
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
const status = sessionStatus(node, t)
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
<div className={css.hoverStatus}>
<StateDot state={node.running ? 'ongoing' : 'done'} />
<span>{node.running ? t('status.running') : t('status.idle')}</span>
<StateDot state={status.state} />
<span>{status.label}</span>
</div>
</div>
)
@@ -248,6 +249,20 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/**
* One top-level 34px session row: status dot (approval waiting outranks
* running), title, relative time, and the row actions menu.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onRename - open the session rename dialog (id + current title).
* @param props.onFork - fork a session at its last completed turn.
* @param props.onArchive - archive a session by id.
* @param props.drag - optional draggable-row wiring.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
node: SessionNode
currentId: string | undefined
@@ -266,6 +281,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const status = sessionStatus(node, t)
const [menuOpen, setMenuOpen] = useState(false)
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
@@ -310,7 +326,14 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
drag.drop(rowHalf(e))
}}
>
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.slot}>
{status.state !== 'done' && (
<>
<StateDot state={status.state} />
<span className={css.visuallyHidden}>{status.label}</span>
</>
)}
</span>
<span className={css.title}>{title}</span>
{/* A blank New Session row is a provisional placeholder: nothing has
happened in it yet, so a "now" timestamp and the row verbs

View File

@@ -20,6 +20,8 @@ export interface SessionNode {
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
/** The runtime Session list reports a pending approval request for this Session. */
waitingApproval: boolean
running: boolean
updatedAt: number
}
@@ -169,6 +171,7 @@ function sessionNode(s: SessionSummary): SessionNode {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
waitingApproval: s.waitingApproval,
running: s.running,
updatedAt: s.updatedAt,
}

View File

@@ -96,7 +96,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -180,7 +180,7 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -206,7 +206,7 @@ describe('workspace browser rows', () => {
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -234,11 +234,12 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -248,7 +249,7 @@ describe('workspace browser rows', () => {
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1分钟前')).toBeTruthy()
expect(screen.getByText('进行中')).toBeTruthy()
expect(screen.getAllByText('进行中')).toHaveLength(2)
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
@@ -260,11 +261,38 @@ describe('workspace browser rows', () => {
}
})
it('shows approval waiting as warning ahead of the running state', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('approval'), title: 'Needs approval', blank: false,
waitingApproval: true, running: true, updatedAt: 0,
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(screen.getByText('等待审批')).toBeTruthy()
view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy()
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getAllByText('等待审批')).toHaveLength(2)
expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('idle hover card shows the Idle status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -279,7 +307,7 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(

View File

@@ -38,6 +38,14 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('projects approval-waiting state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true }
const sessions = list(awaiting)
const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project']))
expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true })
expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true })
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))