Merge pull request #3391 from deepseek-harness/worktree-chatperf

perf(web): reduce conversation rendering and layout overhead
This commit is contained in:
imccyu authored and GitHub committed 2026-09-01 23:16:50 +08:00
commit 1f694c88ab
97 files changed
+2304 -976

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d
2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c
@@ -55,7 +55,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-input-trigger or the command surfaces, input still sends and receives normally — graceful degradation.
- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame. It always owns the same scrollport and composer seat; separate strict-session header and body outlets fill those fixed regions after a Session appears.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the composer surface DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout. The memoized InputBar renders its overlay, left, right, and dock child slots after the renderer has bound their standard props; `ConversationRoot` passes only scalar data and callbacks, so an unrelated shell render does not create fresh ReactNode owner props or invalidate the bar.
- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`: a summary-proven blank Session remains Hero in every open state, while an unproven Session settles during loading. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
- Sending unifies in the hub defaultSink: after an optimistic draft clear it goes only through `session.prompt` with `mode:'queue'` (the Web UI has no steer entry; host-wire `mode:'steer'` remains outside this machine); backfill happens only when it fails and the live draft is still empty — a user who has kept typing is never overwritten. No Draft materialize or attach transaction exists.
- When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft moves from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.
@@ -105,6 +105,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
| Dual draft persistence {text, occurrences} | The mirror writing the clipboard projection adds zero new concepts; chip degradation across refresh is acceptable |
| The native textarea undo stack | Unreliable under controlled + programmatic writes; the paste two-step undo semantics can only be self-managed — both sides retired with the textarea itself; Lexical's history owns undo now |
| The InputBar receiving a 16-member wiring-callback bundle | The consumption matrix proved 11 members InputBar-exclusive and 1 a dead member; the standard-kit channel lets components fetch their own, with the keyboard surface passed privately in-package |
| `ConversationRoot` rendering InputBar's child slots into owner props | Fresh React elements defeat the bar's memo boundary; the bar already receives `renderSlot` and owns the exact positions |
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
@@ -55,7 +55,7 @@ Status: implemented
- hub(trigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-input-trigger/命令面时输入正常收发,优雅降级。
- 每个实体会话只有一个 `SessionInputShell`(facade),随会话作用域创建和拆除;无会话时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。它始终拥有同一个 scrollport 与 composer seat;会话出现后,彼此独立的严格会话 header 和 body outlet 只填入这些固定区域。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。
- composer bar 是一个无条件渲染的 `session-maybe` slot entry:无会话时同一个 InputBar 以惰性态渲染(machine face 缺席、`disabled` owner prop),`connectWorkspace` 返回 blank 会话后同一实例转为 live——编辑器表面 DOM 在无会话 → blank 切换及其后每次 phase 翻转中都不重建;`ConversationRoot`、Hero 与布局骨架全程保持。memoized InputBar 在 renderer 绑定各 child slot 的标准 props 后自行渲染 overlay、left、right 与 dock;`ConversationRoot` 只传标量数据和回调,因此无关 shell render 不会制造新的 ReactNode owner prop 或使 bar 失效。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))`:summary 已证实为空的会话在任何 open state 下都保持 Hero,未经证实的会话则在 loading 期间进入 settling。首次 submit 同步进入 engaging,失败也保留 composer 与错误上下文,不退回 blank Hero;sidebar 的 blank 位只在提示词成功受理后翻 false。
- 发送统一在 hub defaultSink:乐观清稿后只走 `session.prompt` 且固定 `mode:'queue'`(Web UI 无 steer 入口;host 线缆上的 `mode:'steer'` 不经此 machine);失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标会话不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank 会话留存但不再 current。
@@ -105,6 +105,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引
| draft 双持久化 {text, occurrences} | mirror 写剪贴板投影零新概念;chip 跨刷新降级可接受 |
| 原生 textarea undo 栈 | 受控 + 程序化写入下不可靠;粘贴两段 undo 语义只能自管——两侧都随 textarea 一并退役;undo 现归 Lexical history |
| InputBar 收 16 员 wiring 回调包 | 消费矩阵实证 11 员 InputBar 独占、1 员死成员;标准件通道让组件自取,键盘面包内私递 |
| 由 `ConversationRoot` 把 InputBar child slot 渲染为 owner prop | 新 React element 会击穿 bar 的 memo 边界;bar 已收到 `renderSlot`,也拥有这些位置 |
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通提示词;不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名 slot 在注册前保持为空;占位件与真实现冲突时是两个真源 |
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6
2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42
2026-07-29-projected-token-usage-and-request-context.md: 7c984012b7a4b387f1ff24279567fe15aa34cf77
2026-07-29-projected-token-usage-and-request-context.zh.md: b34fb7702898f4a51524a24ad27927c48220bfad
@@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio
Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer.
## Context occupancy is approximate, and that is the decision
@@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec
Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.
Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry; streaming text deltas do not force the stats line to recompute or churn layout-observer subscriptions.
@@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer。
## 上下文占用率是近似值,而这正是决策本身
@@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定,
占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量不会迫使统计行重新计算或反复替换布局 observer 订阅。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
2026-08-09-client-conversation-node-assembly.md: 0aac5056e2cbe22359f8064b1bf4aa0b015a35c8
2026-08-09-client-conversation-node-assembly.zh.md: b06a92113f91e6297da986866dce097b11bab45f
@@ -125,16 +125,16 @@ The Assembler does not use State reference equality to decide publication or pro
| Return value | Behavior |
|---|---|
| `immediate` | Request a notification and flush in the current microtask |
| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
| `animation-frame` | Coalesce high-frequency updates into materialization after three browser animation frames |
| `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost.
Every live delta during the three-frame interval still executes `update()`, while one historical packed run executes one batch `update()`. Location-data publication, `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost. An immediate publication cancels a pending frame interval and flushes the latest State without delay.
#### `buildLocationData(context, scope)`
`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler passes the preceding publication back to its owner, which returns that exact value when its business data is unchanged. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key.
@@ -315,21 +315,21 @@ The shell synchronously resolves the persisted selection when a Session binding
Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources.
For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys.
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order` and resolves the two stable sources for each key. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key, subscribes only to its Node and Turn-process sources, and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes the Node's stable Turn data store as `hookContext`; the Slot renderer binds `useTurnData(businessKey)` directly to that store. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside `useTurnData()` returns only the current Node's `turn.data.get(key)`, so selector equality filters unrelated Session publications.
Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. `useTurnData()` subscribes to `turn.data.source(key)`, so another Location-data key or Session snapshot publication does not notify it.
The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
The standard `useSession` remains available to every session-scoped slot renderer, although `ChatNodeSeat` needs neither it nor aggregate `useChat`. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
Assistant streaming to final and Tool running to settled stay in one Seat while updating its data and necessary ordering properties. Settlement therefore does not reset component-local State through a parent move.
@@ -390,6 +390,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
**Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates.
**Cache every Definition's Location data by State identity.** Rejected because a Definition may mutate and return the same State object, and its Location data may also depend on Match Locations or values published by another Definition. Each Definition instead decides whether its business value changed and returns the preceding publication unchanged when it did not.
**Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation.
**Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts.
@@ -412,11 +414,11 @@ Initial tail, older prepend, and live append share one set of Context invariants
Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per three animation frames. The Assistant view reads the same projection that the preceding Step Location phase installed. Turn Process returns its existing open data and Node for continuing Assistant chunks without deriving or encoding them again, and Turn Tail defers its complete-Match scan until `turn/end`. Step or Turn close and final Events immediately publish the latest State.
An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn, and keyed Location sources isolate unrelated updates.
Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window.
@@ -125,16 +125,16 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成
| 返回值 | 行为 |
|---|---|
| `immediate` | 请求当前 microtask 通知与 flush |
| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
| `animation-frame` | 跨过三个浏览器 animation frame 后,把多条高频更新合并为一次 materialization |
| `none` | 本 Match 不主动安排 flush,State 和 dirty 标记仍被保留 |
省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`,final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 fragment。
三帧间隔内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;Location-data publication、`buildViewNode()`、View Builder 与 React snapshot 通知会合并执行,不会丢失 fragment。immediate publication 会取消等待中的帧间隔,并立即发布最新 State。
#### `buildLocationData(context, scope)`
`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。
`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 会把前一次 publication 传回它的 owner;业务数据未变时,owner 原样返回该值。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。
Definition 分别收到 `step` 和 `turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。
@@ -315,21 +315,21 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化
普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source。
Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`,并为每个 key 解析两份稳定 source。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,只订阅自身的 Node 与 Turn-process source,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 把 Node 所属 Turn 的稳定 data store 作为 `hookContext` 传给 slot;Slot renderer 直接在该 store 上绑定 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。
Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 内部 selector 只返回当前 Node 的 `turn.data.get(key)`,无关 Session publication 会被 selector equality 截断。
Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。`useTurnData()` 订阅 `turn.data.source(key)`,其他 Location-data key 或 Session snapshot 的发布不会通知它。
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,但 `ChatNodeSeat` 不再需要它或聚合 `useChat`。`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
Assistant streaming 到 final、Tool running 到 settled 始终留在同一个 Seat,只更新 data 和必要的排序属性。结算不会因跨 parent 移动而重置组件内部 State。
@@ -390,6 +390,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。
**按 State identity 缓存每个 Definition 的 Location data。** 拒绝:Definition 可以原地修改并返回同一个 State 对象,其 Location data 也可能依赖 Match Location 或其他 Definition 发布的 value。各 Definition 改为自行判断业务值是否变化;未变化时原样返回前一次 publication。
**增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。
**在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。
@@ -412,11 +414,11 @@ Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deep
Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
State update 与 publication cadence 分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每三个 animation frame 最多 materialize 一次。Assistant view 读取前置 Step Location 阶段刚写入的同一 projection。Turn Process 对持续 Assistant chunk 直接返回已有 open data 和 Node,不再重复派生或编码;Turn Tail 到 `turn/end` 才执行完整 Match 扫描。Step/Turn close 与 final Event 会立即发布最新 State。
inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。
Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 keyed Location source 隔离无关更新。
Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md
2026-08-11-trajectory-conversation-context-assembly.md: c041d7a8ba9f39f74cf55f8a35513724eaeb4f54
2026-08-11-trajectory-conversation-context-assembly.zh.md: 06e7b801b67b789ddffe991916aceee86cc903f8
2026-08-11-trajectory-conversation-context-assembly.md: fd246922b0ecce146da00e212ecb8053a939017d
2026-08-11-trajectory-conversation-context-assembly.zh.md: 2568de889fc3b6c227d9faa51166186b388de18f
@@ -74,7 +74,7 @@ The Context migration and the following presentation optimizations solve differe
| Following Assistant lookup | One reverse pass records the next Assistant for every input position | The former repeated forward lookup falls from worst-case `O(C²)` to `O(C)` |
| Group duration | Fixed decimal grouping replaces `toLocaleString('en-US')` for the invariant English numeric shape | Complexity remains linear in Groups, but the Intl formatter leaves the repeated render path |
Display memoization and search indexing stay separate. Search must include off-screen records and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence.
Display memoization and search indexing stay separate. Search includes off-screen records in the current React-visible history window and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence.
## Alternatives considered
@@ -88,7 +88,7 @@ Display memoization and search indexing stay separate. Search must include off-s
**Replace the Trajectory stages with generic Conversation Nodes.** Rejected: stages organize requests, timing, schemas, and table layout for one view. Making them engine contracts would constrain a future plain Session-log view and return view-specific composition to Client Runtime.
**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete loaded record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers.
**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete React-visible record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers.
## Verification
@@ -100,7 +100,7 @@ Trajectory Definition and Builder tests pin Assistant streaming and interruption
Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session.
After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. The search index still performs a light linear signature pass when its input layout changes.
After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. Each Trajectory view mount anchors React layout, timeline, and search data to 50 target Nodes at the current tail; live appends extend that window, and the existing earlier-history action extends its prefix before it requests another Session page. If a replacement window no longer contains the previous tail anchor, the same render uses the replacement's latest Node as its bound and adopts that anchor for subsequent appends. Request numbering and cumulative usage remain derived from the complete resident snapshot. The search index still performs a light linear signature pass when its input layout changes.
Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity.
@@ -74,7 +74,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保
| 后继 Assistant 查找 | 一次反向遍历为每个输入位置记录后续 Assistant | 原先重复向前查找的最坏复杂度从 `O(C²)` 降为 `O(C)` |
| Group duration | 以固定十进制分组替代固定英文数字形态下的 `toLocaleString('en-US')` | 复杂度仍与 Group 数线性相关,但重复 render 路径不再调用 Intl formatter |
展示 memo 与搜索索引彼此独立。搜索必须覆盖屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。
展示 memo 与搜索索引彼此独立。搜索覆盖当前 React 可见历史窗口中的屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。
## 考虑过的替代方案
@@ -88,7 +88,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保
**用通用 Conversation Node 替换 Trajectory stage。** 不予采纳:stage 为单一视图组织 Request、计时、schema 和表格 layout。把它变成引擎约定会限制未来的朴素 Session-log 视图,并把视图专属组合重新放回 Client Runtime。
**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部已加载 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。
**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部 React 可见 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。
## 验证
@@ -100,7 +100,7 @@ Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interrupt
Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。
首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。
首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。每次挂载 Trajectory 视图时,React layout、timeline 与搜索数据都锚定在当前尾部的 50 个 target Node;实时 append 会扩展该窗口,现有更早历史操作则先扩展其前缀,再请求下一个 Session 页面。如果 replacement window 不再包含先前的尾锚,同一次 render 会以替换窗口的最新 Node 为边界,并把该节点采纳为后续 append 的新锚。请求编号与累计用量仍从完整的驻留 snapshot 派生。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。
Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77
2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef
2026-08-20-client-session-conversation-ownership.md: 34848b3d9be4046993f11290a96f963d9cc78bba
2026-08-20-client-session-conversation-ownership.zh.md: 48557a2fc81a71858cc38c96fea630c0aa190c89
@@ -298,7 +298,7 @@ Draft and input state belong to Conversation UI and do not enter the Session sna
`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration.
It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`.
It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatView` uses `useChat` for aggregate order, navigation, and timeline reads; each `ChatNodeSeat` receives identity-stable Node and Turn-process sources from that snapshot and does not subscribe to the aggregate source.
Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation.
@@ -298,7 +298,7 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue
`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。
它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。
它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatView` 使用 `useChat` 读取聚合 order、navigation 与 timeline;每个 `ChatNodeSeat` 从该 snapshot 接收身份稳定的 Node 与 Turn-process source,不订阅聚合 source。
Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
2026-07-31-web-workspace-file-links.md: ddc093e1ce5e646f6b96f1517b2a26c9e10fe423
2026-07-31-web-workspace-file-links.zh.md: f463de969bab7a47145abbbd46363b8807bcc673
2026-07-31-web-workspace-file-links.md: 944d7bef9b83eb34895c5f74e511600a713d786b
2026-07-31-web-workspace-file-links.zh.md: 71f0051204e0cb8b65995fc88a9edb230db8f5be
@@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had
## Decision
**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The single-line lane measures its chips and localized remainder, then shows the largest prefix that fits (up to six) plus `+ N files`. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing.
**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. CSS container-width bands select a prefix of up to six paths and its matching `+ N files` label, while flexbox shrinks and ellipsizes the visible basenames; the component performs no JavaScript layout observation ([decision](../simplification/2026-09-01-css-produced-file-layout.md)). One cordis.yml line composes the surface in or out; the unregistered hole renders nothing.
**The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix.
@@ -28,9 +28,9 @@ Two distinct defects sat behind that. The transcript never said what a turn had
- **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable).
- **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still rendered perfectly, so the breakage was invisible.
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead.
- **Horizontal chip scrolling** — keeps every file in the DOM but makes the hidden tail undiscoverable, adds a nested horizontal gesture to the transcript, and provides no exact account of what is out of view. One measured line with a stable remainder preserves the answer's vertical rhythm and keeps the omission explicit.
- **Horizontal chip scrolling** — keeps every file in the DOM but makes the hidden tail undiscoverable, adds a nested horizontal gesture to the transcript, and provides no exact account of what is out of view. A non-scrolling CSS lane with a width-selected visible prefix preserves the answer's vertical rhythm and keeps the omission explicit.
- **An embedded WebView in the desktop shell** — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take.
## Consequences
Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers overflow geometry and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while `<script src>`, `<img>`, and CSS `@import` work), the one capability HTTP serving had that this does not. Remote clients keep the chips but omit the folder action; the full path remains in each chip's `title`. Markdown still opens in the platform's `.md` application; in-product rendering is separate work.
Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers single-line CSS overflow and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while `<script src>`, `<img>`, and CSS `@import` work), the one capability HTTP serving had that this does not. Remote clients keep the chips but omit the folder action; the full path remains in each chip's `title`. Markdown still opens in the platform's `.md` application; in-product rendering is separate work.
@@ -14,7 +14,7 @@ Status: implemented
## 决策
**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。单行 lane 会测量 chip 和本地化剩余计数,再显示能放下的最大前缀(至多六个)及 `+ N 个文件`。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。CSS 容器宽度档位会选择前六条路径中的一个前缀及其匹配的 `+ N 个文件` 标签,flexbox 则收缩可见的 basename 并用 ellipsis 省略;组件不执行 JavaScript 布局观察([决策](../simplification/2026-09-01-css-produced-file-layout.zh.md))。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。
@@ -28,9 +28,9 @@ Status: implemented
- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到包含所有会话 transcript 的 35 KB,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。
- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(「在结尾附上链接」),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。
- **让文件 chip 横向滚动**——这样会把每个文件都留在 DOM 中,却使隐藏的尾部难以发现,在 transcript 内增加一层横向手势,也无法精确说明视口外还有什么。经过测量的一行和稳定的剩余计数既保留回答的纵向节奏,也明确呈现省略量。
- **让文件 chip 横向滚动**——这样会把每个文件都留在 DOM 中,却使隐藏的尾部难以发现,在 transcript 内增加一层横向手势,也无法精确说明视口外还有什么。不滚动的 CSS 单行与按宽度选择的可见前缀既保留回答的纵向节奏,也明确呈现省略量。
- **桌面端外壳中的内嵌 WebView**——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。
## 后果
现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖溢出几何和单次点击的 Host 交接,且不会启动原生应用。产出的 `file://` 文档无法 `fetch` 同级文件(但 `<script src>`、`<img>` 和 CSS `@import` 可用),这是 HTTP 提供曾有、而此处没有的能力。远程客户端保留 chip,但省略文件夹操作;每个 chip 的 `title` 仍保留完整路径。Markdown 仍由平台的 `.md` 应用打开;产品内渲染属于另一项工作。
现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖单行 CSS 溢出和单次点击的 Host 交接,且不会启动原生应用。产出的 `file://` 文档无法 `fetch` 同级文件(但 `<script src>`、`<img>` 和 CSS `@import` 可用),这是 HTTP 提供曾有、而此处没有的能力。远程客户端保留 chip,但省略文件夹操作;每个 chip 的 `title` 仍保留完整路径。Markdown 仍由平台的 `.md` 应用打开;产品内渲染属于另一项工作。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-turn-run-time.md
2026-08-03-web-turn-run-time.md: b6e79b34f45ebe46d9ce752b6333cdfce6fc4dd6
2026-08-03-web-turn-run-time.zh.md: 73d9be4d2119278f8a78c6858bae353a4ef5d62f
2026-08-03-web-turn-run-time.md: c77a365d66ea03b50275be21754f38ee449be14f
2026-08-03-web-turn-run-time.zh.md: ca7fdc6ef12def71efaa3b4267d8833e78cdc548
@@ -12,7 +12,7 @@ The Web chat shows when a message arrived but not how long the agent worked on i
Turn wall time uses the existing logged `turn/start` and `turn/end` timestamps, with no new session events. The client Session folds each in-window pair into `turnTimings`; the actions-owning assistant footer renders `endTime - startTime` as a localized `Ran for {duration}` label after the turn ends. The running `TurnStatus` clock uses the latest timing without an end, so reload preserves elapsed time, steering does not reset it, and a retry starts from its own logged boundary. Both readings use the same localized formatter and whole-second floor. The clock appears only after 15 seconds and is hidden from the live region so screen readers announce the activity status without replaying every tick.
Time chrome (clock and run time) is hover-revealed: message containers opt in with a `data-time-hover-root` attribute, and `MessageIconActions.module.css` fades the time label in on container `:hover`/`:focus-within`. The rule is scoped to `@media (hover: hover)`, so touch devices keep the always-visible label; opacity (not display) keeps the layout stable. Copy/branch icons stay always visible.
Action chrome is recency-gated on hover-capable devices: the latest user-authored row and latest Turn tail remain visible, while each earlier row fades the complete actions line in on `:hover` or `:focus-within`. Turn tails publish an explicit recency attribute. User and steering rows use a CSS following-sibling selector over their existing flow-kind attributes, so no mounted message subscribes to and reverse-scans the aggregate Chat snapshot. Touch devices keep every action line visible, and opacity preserves layout.
## Alternatives considered
@@ -20,8 +20,8 @@ Time chrome (clock and run time) is hover-revealed: message containers opt in wi
**Anchoring the live clock to component mount.** Simpler, but a mid-turn reload would restart the clock at zero and disagree with the eventual footer label. Mount time remains only the fallback when `turn/start` is outside the loaded window.
**Hiding the whole actions row until hover.** Copy and branch are affordances worth discovering, and row-level show/hide risks layout shift. Only the passive time text is hover-gated.
**Compute the latest user-authored row inside every message renderer.** Rejected because each mounted row would subscribe to the aggregate Chat snapshot and reverse-scan its order whenever any Chat value changed. The flow already expresses row order and kind in the DOM, so CSS owns this visual recency rule.
## Consequences
Turn duration is visible live and after settlement without new session events, and both readings share exact log boundaries and formatting. The settled duration includes activity after the last assistant text up to `turn/end`; the label is absent when `turn/start` is outside the loaded window. Time chrome no longer competes with message content at rest, and the ticking clock remains visual rather than repeatedly announced.
Turn duration is visible live and after settlement without new session events, and both readings share exact log boundaries and formatting. The settled duration includes activity after the last assistant text up to `turn/end`; the label is absent when `turn/start` is outside the loaded window. Earlier action rows do not compete with message content at rest, their hidden opacity still reserves layout, and the ticking clock remains visual rather than repeatedly announced.
@@ -12,7 +12,7 @@ Web 聊天界面会显示消息的到达时间,却不显示 agent(智能体
轮次实际耗时(wall time)采用日志中已有的 `turn/start` 和 `turn/end` 时间戳,不新增任何会话事件。客户端 Session 将加载窗口内的每对边界归并到 `turnTimings` 中;轮次结束后,承载操作图标的 assistant 页脚把 `endTime - startTime` 渲染为本地化的 `Ran for {duration}` 标签。运行中的 `TurnStatus` 时钟采用最新一条没有结束时间的计时记录,因此重新加载会保留已用时长,steering(中途引导)不会重置计时,重试也从自身的日志边界开始。两处读数共用同一个本地化格式化器,并向下取整到整秒。该时钟在 15 秒后才出现,并从实时区域中隐藏,因此屏幕阅读器会播报活动状态而不会重复播报每次时钟跳动。
时钟与运行时长这类时间附属元素(time chrome)在悬停时才显示:消息容器通过 `data-time-hover-root` 属性显式启用该行为,`MessageIconActions.module.css` 在容器处于 `:hover`/`:focus-within` 时以淡入方式显示时间标签。该规则限定在 `@media (hover: hover)` 之内,触屏设备因此保持标签始终可见;显隐通过 opacity(而非 display)实现,布局保持稳定。复制与分支图标始终可见。
在支持 hover 的设备上,操作附属元素按新近程度显示:最新的用户消息行与最新 Turn 尾部保持可见,每个更早的行只在 `:hover` 或 `:focus-within` 时淡入完整操作行。Turn 尾部发布显式的新近属性;用户与 steering 行通过既有 flow-kind 属性上的 CSS 后继兄弟选择器判定,因此每个已挂载消息都无需订阅并反向扫描聚合 Chat snapshot。触屏设备保持每条操作行可见,opacity 则保持布局稳定。
## 考虑过的替代方案
@@ -20,8 +20,8 @@ Web 聊天界面会显示消息的到达时间,却不显示 agent(智能体
**将实时时钟锚定到组件挂载时刻。** 更简单,但轮次进行中重新加载会让时钟从零重新计时,并与最终的页脚标签不一致。仅当 `turn/start` 位于已加载窗口之外时,才回退到挂载时刻。
**将整个操作行隐藏至悬停时才显示。** 复制与分支是值得让用户发现的操作入口,而整行级别的显隐切换有布局偏移的风险。只有被动的时间文本由悬停控制显隐。
**在每个消息 renderer 内计算最新的用户消息行。** 不采用:每个已挂载行都会订阅聚合 Chat snapshot,并在任意 Chat 值变化时反向扫描 order。现有 DOM flow 已表达行顺序和 kind,因此该视觉新近规则归 CSS 所有。
## 后果
轮次时长在运行中和结束后都可见,且不需要新的会话事件;两处读数共用精确的日志边界和格式化方式。结束后的时长包括最后一条 assistant 文本之后、直至 `turn/end` 的活动;若 `turn/start` 位于已加载窗口之外,则不显示标签。未交互时,时间附属元素不再与消息内容争夺注意力,持续跳动的时钟也只保留视觉呈现,不会被重复播报。
轮次时长在运行中和结束后都可见,且不需要新的会话事件;两处读数共用精确的日志边界和格式化方式。结束后的时长包括最后一条 assistant 文本之后、直至 `turn/end` 的活动;若 `turn/start` 位于已加载窗口之外,则不显示标签。较早的操作行在未交互时不与消息内容争夺注意力,透明隐藏仍保留布局,持续跳动的时钟也只保留视觉呈现,不会被重复播报。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md
2026-08-14-web-turn-process-folding.md: fd0015dc915a296725344cd3f4ae05124089adf6
2026-08-14-web-turn-process-folding.zh.md: 47b1a36bd75695a2f04dfed654d45ec81d2b8bc9
2026-08-14-web-turn-process-folding.md: e0d666a0e5c744347ecb0f769aa4354481406ce4
2026-08-14-web-turn-process-folding.zh.md: cacf7ecfdc37f0f155b0eedb894550fe9740dd0d
@@ -14,11 +14,13 @@ The Host-backed `ui-chat.transcriptView` preference selects `normal` or `compact
In Compact mode, a Turn remains fully expanded while it is open. At `turn/end`, its latest Step becomes a final-answer boundary only when it contains user-facing Assistant reply content—non-blank text, an image, or an unknown visible block—and contains no Tool-call block. The answer remains visible. Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows before that boundary form one process disclosure. System prompt, User, and steering rows remain independent and never join the group. Completed, aborted, interrupted, failed, and max-token Turns use the same terminal projection; error, max-token, and turn-tail rows remain outside the group. A closed Turn with no final answer keeps all process evidence visible.
The Turn-scoped `turn-process` Definition derives the first model or Tool evidence, the latest Step's finalized answer boundary, reply-bearing durable Assistant-message count before that answer, and Tool-call counts from log events plus Step Location data. Shipped subagent delegation names (`subagent` and `subagent_*`) increment the subagent count instead of the ordinary Tool-call count, so the categories never overlap. Context injection remains process evidence without incrementing a summary count; System prompt is independent, stays visible, and remains before the opening User. It publishes an encoded scalar so unchanged facts retain value identity during streaming and contributes one stable control Chat Node. The Chat target positions opening User or steering input before process candidates from their first projection, then inserts the synthetic control between that input and the process rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. Answer finalization, Retry, later Steps, completion, and manual expansion therefore change visibility without changing existing nodes' relative order, as specified by [stable Turn-process ordering](../bug-fix/2026-08-26-stable-turn-process-order.md). Each process member classifies itself from the Turn specification. Only the control and answer Seats subscribe to their Turn's content-revisioned Location key list and derive external process evidence plus independent-input spacing from that bounded list, so data-only updates refresh layout without rebuilding the global order. Turn status and loaded-window completeness then gate foldability: an open Turn never folds, and a partial history exposes neither the control nor hidden members. The control Node exists from the first process evidence onward but its Seat remains hidden until the closed Turn has a final answer and complete history. Once visible, it omits every zero-valued segment, uses `Thought for a while` when all three counts are zero, and places a full-width divider below the summary.
The Turn-scoped `turn-process` Definition derives the first model or Tool evidence, the latest Step's finalized answer boundary, reply-bearing durable Assistant-message count before that answer, and Tool-call counts from log events plus Step Location data. Shipped subagent delegation names (`subagent` and `subagent_*`) increment the subagent count instead of the ordinary Tool-call count, so the categories never overlap. Context injection remains process evidence without incrementing a summary count; System prompt is independent, stays visible, and remains before the opening User. The Definition publishes one immutable `TurnProcessSpec` directly to both Turn Location data and its stable control Chat Node; continuing open-stream updates reuse both values while their fields remain unchanged. The Chat target positions opening User or steering input before process candidates from their first projection, then inserts the synthetic control between that input and the process rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. Answer finalization, Retry, later Steps, completion, and manual expansion therefore change visibility without changing existing nodes' relative order, as specified by [stable Turn-process ordering](../bug-fix/2026-08-26-stable-turn-process-order.md).
`ChatTurnProcessProjector` owns the cross-Node presentation facts. It scans only an affected Turn when Node structure, `TurnProcessSpec`, or Turn status changes, retains an equal presentation by reference, and publishes a stable process source only to Seats in a changed Turn. Each Seat classifies its own membership and answer role from that shared presentation without reading the global Chat snapshot, scanning the Turn, or encoding and decoding a signature. Turn status and loaded-window completeness gate foldability: an open Turn never folds, and a partial history exposes neither the control nor hidden members. The control Node exists from the first process evidence onward but its Seat remains hidden until the closed Turn has a final answer and complete history. Once visible, it omits every zero-valued segment, uses `Thought for a while` when all three counts are zero, and places a full-width divider below the summary.
The Chat target binds the durable transcript preference through the shared settings scope and keeps per-Turn interaction state in its session-scoped Chat store. `ChatView` renders each business Node through one stable keyed `ChatNodeSeat`; adding the process control does not reorder existing keys, and Compact mode changes the Seat wrapper's `hidden` attribute without reparenting or unmounting a Tool, Assistant, Context, or Retry renderer. The Seat passes the same process state through `ChatNodeOwnerProps`, so the final Assistant renderer hides reasoning blocks from its own Step while leaving reply blocks visible; wrapper visibility and inline reasoning therefore share one UI-state source.
Closed process members use `hidden="until-found"`. A `beforematch` event on any member opens the shared group in supporting browsers. The Chat column applies spacing only between visible siblings because hidden-until-found members retain searchable zero-height boxes; the control's divider spans the content width, and a closed process control uses an 8px answer gap only when no independent input intervenes, while expansion restores the ordinary 16px row spacing. In Compact mode, the non-persisted session store contains only manually expanded Turn-and-answer-Step generations; absence means collapsed, and a different answer generation starts collapsed. Every eligible closed Turn therefore uses the same default regardless of whether it completed live, appeared after Load earlier, or closed while the reader was away from the tail. This can reflow content above the reader when a Turn closes or history becomes complete. An automatic collapse that would hide a focused process descendant opens the shared group instead, leaving keyboard focus in place; a manual close focuses the process control before hiding its members. If Load earlier is present, every process remains expanded and its control stays hidden; once history is complete, eligible groups immediately use the collapsed default. A fresh page load restores the durable Normal or Compact preference; per-Turn manual expansion survives only view remounts within the same page lifetime. Switching to Normal reveals every process row, while switching back to Compact reapplies the page-lifetime manual overrides over the collapsed default.
Closed process members use `hidden="until-found"`. A `beforematch` event on any member opens the shared group in supporting browsers. The Chat column applies spacing only between visible siblings because hidden-until-found members retain searchable zero-height boxes; the control's divider spans the content width, and a closed process control uses an 8px answer gap only when no independent input intervenes, while expansion restores the ordinary 16px row spacing. A collapsed Think row follows the latest streamed line through CSS, has one font-axis-adjusted fixed row height, and applies size and layout containment; expansion removes that containment and restores natural prose height. In Compact mode, the non-persisted session store contains only manually expanded Turn-and-answer-Step generations; absence means collapsed, and a different answer generation starts collapsed. Every eligible closed Turn therefore uses the same default regardless of whether it completed live, appeared after Load earlier, or closed while the reader was away from the tail. This can reflow content above the reader when a Turn closes or history becomes complete. An automatic collapse that would hide a focused process descendant opens the shared group instead, leaving keyboard focus in place; a manual close focuses the process control before hiding its members. If Load earlier is present, every process remains expanded and its control stays hidden; once history is complete, eligible groups immediately use the collapsed default. A fresh page load restores the durable Normal or Compact preference; per-Turn manual expansion survives only view remounts within the same page lifetime. Switching to Normal reveals every process row, while switching back to Compact reapplies the page-lifetime manual overrides over the collapsed default.
This presentation composes with [Conversation Node assembly](../architecture/2026-08-09-client-conversation-node-assembly.md): Definitions own deterministic process facts, the Seat owns shared interaction state, and keyed renderers remain independent. The [log-ordered human transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.md) remains complete because folding changes no session event or model input.
@@ -14,11 +14,13 @@ Status: implemented
在 Compact 模式下,轮次打开期间始终完整展开。到 `turn/end` 时,只有当最近步骤包含面向用户的 Assistant 回复内容——非空文本、图片或未知可见块——并且不含工具调用块时,该步骤才成为最终正文边界。正文保持可见。边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行统一进入一条过程 disclosure。系统提示词、用户消息与 steering 消息保持独立,绝不加入过程组。已完成、已取消、已中断、失败和达到最大 token 的轮次使用同一终态投影;错误、最大 token 与 turn-tail 行留在过程组外。关闭时没有最终正文的轮次会保留全部过程证据。
轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。它发布编码后的标量,使未变化的事实在流式期间保持值相等,并贡献一个稳定控制 Chat Node。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。每个过程成员根据轮次规格判断自身归属。只有控制 Seat 与正文 Seat 订阅所属轮次在内容变化时更新的 Location key 列表,并在这份受限列表内推导外部过程证据和独立输入间距,因此纯数据更新会刷新布局,而不必重建全局顺序。轮次状态与已加载窗口是否完整随后共同决定能否折叠:打开中的轮次绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的轮次拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。
轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。Definition 把同一份不可变 `TurnProcessSpec` 直接发布到 Turn Location data 与稳定控制 Chat Node;持续 open stream 的更新在字段不变时复用两份值。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。
`ChatTurnProcessProjector` 拥有跨 Node 的呈现事实。只有 Node 结构、`TurnProcessSpec` 或 Turn 状态变化时,它才扫描受影响的 Turn;相等的呈现会按引用保留,且只有变化 Turn 内的 Seat 会收到稳定 process source 的发布。每个 Seat 从共享呈现判断自身的过程成员与正文角色,无需读取全局 Chat snapshot、扫描 Turn 或编码再解码签名。Turn 状态与已加载窗口是否完整共同决定能否折叠:打开中的 Turn 绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的 Turn 拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。
Chat target 通过共享 settings scope 绑定持久化的 transcript 偏好,并把逐轮交互状态保存在会话作用域的 Chat store 中。`ChatView` 通过稳定的 keyed `ChatNodeSeat` 直接渲染每个业务 Node;加入过程控件不会重排既有 key,Compact 模式只改变 Seat wrapper 的 `hidden` 属性,不会重新挂接或卸载工具、Assistant、上下文或重试 renderer。Seat 通过 `ChatNodeOwnerProps` 传递同一份过程状态,因此最终 Assistant renderer 会隐藏自身步骤中的推理块,同时保留回复块;wrapper 可见性与行内推理共用一个 UI 状态真源。
收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「轮次 + 正文步骤」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭轮次都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在轮次关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐轮手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。
收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。收起的 Think 行通过 CSS 跟随最新流式文本行,具有随字号轴变化的单行固定高度,并启用 size 与 layout containment;展开时移除 containment,正文恢复自然高度。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「Turn + 正文 Step」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭 Turn 都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在 Turn 关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐 Turn 手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。
这项展示与 [Conversation Node 组装](../architecture/2026-08-09-client-conversation-node-assembly.zh.md)共同成立:Definition 持有确定性的过程事实,Seat 持有共享交互状态,keyed renderer 保持独立。[按日志顺序投影的人工 transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md)保持完整,因为折叠不改变任何会话事件或模型输入。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-01-css-produced-file-layout.md
2026-09-01-css-produced-file-layout.md: fce7d70b21b05db8970fb249927c36f87abc6b23
2026-09-01-css-produced-file-layout.zh.md: bc8ad941dbe33af3acc629f0da50e79c4a0954da
@@ -0,0 +1,27 @@
# Agent Note: replace produced-file probes with CSS width bands
Status: implemented
English | [中文](2026-09-01-css-produced-file-layout.zh.md)
## Problem
The produced-files row duplicated every candidate chip in a hidden probe tree, synchronously read computed styles and element geometry in a layout effect, and repeated those reads whenever the row or a probe resized. That machinery existed only to choose how many labels fit on one line. Its forced layout work cost more than exact width-dependent chip counts were worth.
The produced-file discovery and Host-opening behavior remain owned by [opening a produced file from the web UI](../feature/2026-07-31-web-workspace-file-links.md). This decision changes only how that row handles limited horizontal space.
## Decision
The produced-files row is an inline-size query container. CSS width bands hide trailing chips from the six-file prefix and select the corresponding pre-rendered localized remainder label. The flex row performs the actual shrinking, and each basename uses CSS ellipsis while the selected remainder label keeps its intrinsic width. `ProducedFiles` has no resize observer, layout effect, layout state, duplicate chip probes, computed-style lookup, or element-geometry read.
The row keeps its chip maximum and gap as local CSS custom properties. Each width band corresponds to that budget and reveals one matching remainder label when paths are omitted, so `+ N files` remains accurate for the CSS-selected prefix. The same selected label controls whether the existing Host-gated **Show in folder** action is visible.
## Alternatives considered
- **Observe only the row and estimate from its width** — this removes content measurement but still creates one observer and width-driven React state per mounted result row even though CSS already receives the same width.
- **Always render six chips and let flexbox shrink them** — this removes all sizing logic but can reduce six filenames to nearly empty targets on a narrow conversation column. CSS width bands retain useful labels without runtime observation.
- **Wrap or horizontally scroll every chip** — wrapping changes the turn's vertical rhythm, while horizontal scrolling makes the tail hard to discover. The fixed cap keeps the row bounded without either interaction.
## Consequences
Mounting and resizing the row performs no JavaScript layout work. Because fixed width bands ignore actual text widths and localized remainder widths, they may show one more or fewer chip than exact measurement would; flex shrinking and clipping preserve the single-line layout, and the selected `+ N files` label still counts every omitted path. Rendered files preserve their full path in the accessible name and `title`. Each row carries at most six short remainder spans, of which CSS exposes zero or one. The assembled browser test verifies responsive omission, one-line layout, and the absence of horizontal overflow.
@@ -0,0 +1,27 @@
# Agent Note: 以 CSS 宽度档位替代产出文件探针
Status: implemented
[English](2026-09-01-css-produced-file-layout.md) | 中文
## 问题
产出文件行会在隐藏探针树中复制每个候选 chip,在 layout effect 中同步读取计算样式和元素几何,并在该行或任一探针改变尺寸时重复读取。这套机制只用于决定一行能容纳多少标签,其强制布局工作不值得用来换取随宽度精确变化的 chip 数量。
产出文件发现与 Host 打开行为仍由[从 web UI 打开产出的文件](../feature/2026-07-31-web-workspace-file-links.zh.md)所有。本决策只改变该行处理有限横向空间的方式。
## 决策
产出文件行是一个 inline-size 查询容器。CSS 宽度档位会隐藏六文件前缀尾部的 chip,并选择对应的预渲染本地化剩余计数标签。flex 行负责实际收缩,各 basename 由 CSS 以 ellipsis 省略,而选中的剩余计数标签保持自身宽度。`ProducedFiles` 不含 resize observer、layout effect、布局 state、重复 chip 探针、计算样式查询或元素几何读取。
该行以局部 CSS 自定义属性保存 chip 最大宽度与间距。每个宽度档位对应这份预算,并在省略路径时显示一个匹配的剩余计数标签,因此 `+ N 个文件` 对 CSS 选中的前缀保持准确。同一个选中标签控制现有 Host 能力约束下的**在文件夹中显示**动作是否可见。
## 考虑过的替代方案
- **只观察该行并根据其宽度估算**——这会删除内容测量,却仍会为每个已挂载结果行创建一个 observer 和宽度驱动的 React state,而 CSS 已经获得同一份宽度。
- **始终渲染六个 chip 并让 flexbox 收缩**——这会删除全部尺寸逻辑,却可能在狭窄会话列中把六个文件名压缩成近乎空白的点击目标。CSS 宽度档位无需运行时观察即可保留有用标签。
- **换行或横向滚动全部 chip**——换行会改变轮次的纵向节奏,横向滚动则使尾部难以发现。固定上限无需这两种交互即可约束该行。
## 后果
该行挂载与改变尺寸时不执行 JavaScript 布局工作。由于固定宽度档位忽略实际文本宽度与本地化剩余计数宽度,它可能比精确测量多显示或少显示一个 chip;flex 收缩与裁切会保持单行,选中的 `+ N 个文件` 标签仍准确统计全部未展示路径。已渲染文件在无障碍名称与 `title` 中保留完整路径。每行至多携带六个短剩余计数 span,其中 CSS 展示零个或一个。组装层浏览器测试验证响应式省略、单行布局与无横向溢出。
+10 -4
View File
@@ -188,10 +188,16 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await input.press('Enter')
if (MODE !== 'record') {
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
await expect.poll(async () => await liveTail.evaluate(element => (
element.scrollWidth > element.clientWidth
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
)), { timeout: 10_000, interval: 10 }).toBe(true)
await expect.poll(async () => {
if (await liveTail.count() !== 1) return false
return await liveTail.evaluate((element) => {
const text = element.firstElementChild
if (!(text instanceof HTMLElement)) return false
const viewport = element.getBoundingClientRect()
const content = text.getBoundingClientRect()
return content.width > viewport.width && Math.abs(content.right - viewport.right) <= 1
})
}, { timeout: 10_000, interval: 10 }).toBe(true)
}
return await settled
} finally {
+3
View File
@@ -158,6 +158,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 30_000 })
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
+12 -8
View File
@@ -1,6 +1,6 @@
// Web e2e scenario: the single-line produced-files summary a finished turn
// ends with. Cold-seeds ten writes (zero model calls), then verifies the real
// assembled lane keeps a precise +N and a capability-gated folder handoff.
// assembled lane adapts from a coarse width budget and keeps a capability-gated folder handoff.
// The folder request is intercepted so one real browser click can exercise
// the full client carrier without launching a native application in CI.
import { fileURLToPath } from 'node:url'
@@ -20,7 +20,7 @@ const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.met
const SEED_ID = 'produced-files-web-e2e'
const DONE = 'PRODUCED_FILES_DONE'
/** Short leading names plus a long third name make the narrow lane deterministically show two. */
/** Ten varied names exercise estimated prefix selection and CSS shrinking. */
const PRODUCED = [
'关于我.md',
'index.html',
@@ -116,7 +116,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
page = await newEnglishPage(browser)
// Keep the responsive sidebar available while selecting the cold seed;
// the assertion itself narrows the conversation after navigation.
await page.setViewportSize({ width: 1280, height: 900 })
await page.setViewportSize({ width: 1800, height: 900 })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -127,7 +127,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('keeps a narrow ten-file summary on one line with +8 and a folder action', async () => {
it.skipIf(MODE === 'record')('adapts a ten-file summary without leaving one line', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
@@ -137,14 +137,18 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
await sessionRow.click()
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await page.setViewportSize({ width: 780, height: 900 })
const row = page.locator('[data-produced-files-row]')
await row.waitFor({ timeout: 15_000 })
const chips = row.getByRole('button')
await expect.poll(() => chips.count()).toBe(2)
await expect.poll(() => chips.count()).toBe(6)
await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
await page.setViewportSize({ width: 780, height: 900 })
await expect.poll(() => chips.count()).toBe(5)
expect(await chips.nth(0).innerText()).toBe('关于我.md')
expect(await chips.nth(1).innerText()).toBe('index.html')
expect(await row.getByText('+ 8 files', { exact: true }).count()).toBe(1)
expect(await chips.nth(4).innerText()).toBe('app.ts')
await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true })
expect(await showFolder.count()).toBe(1)
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
@@ -163,7 +167,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
openPath.mockRestore()
}
const tops = await row.locator(':scope > *').evaluateAll(elements =>
const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
elements.map(element => element.getBoundingClientRect().top))
expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)
const geometry = await row.evaluate(element => ({
@@ -255,6 +255,11 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
await compareOrRefreshGolden(LOAD_MORE_EXPECTED, loadMoreSnapshot, MODE)
// Avoid Playwright scrolling the offscreen first row into the automatic-load threshold.
await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
await expect.poll(() => logicalRows(page), { timeout: 15_000 }).toBeGreaterThan(initialRows)
const residentRows = await logicalRows(page)
expect(held).toBe(false)
await expect.poll(() => loadMore.isDisabled(), { timeout: 15_000 }).toBe(false)
await loadMore.evaluate((button: HTMLButtonElement) => { button.click() })
await expect.poll(() => held, { timeout: 15_000 }).toBe(true)
await expect.poll(async () => ({
disabled: await loadMore.isDisabled(),
@@ -274,7 +279,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
.toBe('true')
releaseHistory()
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(initialRows)
await expect.poll(() => logicalRows(page), { timeout: 60_000 }).toBeGreaterThan(residentRows)
await nextPaint(page)
await expect.poll(async () => {
const top = await rowTop(page, anchor.key)
+3
View File
@@ -139,6 +139,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 10_000 })
// Only the user bubble owns a footer (clock + copy; user bubbles carry no
// branch action): the narration is not the answer yet.
const copyButtons = page.getByRole('button', { name: 'Copy' })
+11 -9
View File
@@ -31,16 +31,12 @@ import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/Tr
import { createChatStore } from './stores.ts'
import { TranscriptViewPolicy } from './transcript-view.ts'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
import { useTurnDataValue } from './chat/use-turn-data.ts'
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
hooks: {
turnData: ({ useChat }, nodeKey) => function useTurnData(key) {
return useChat((snapshot) => {
const location = snapshot.nodes.get(nodeKey)?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get(key)
: undefined
})
turnData: (_standard, data) => function useTurnData(key) {
return useTurnDataValue(data, key)
},
},
}
@@ -108,10 +104,16 @@ export function apply(ctx: Context): void {
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const session = ctx.sessions.binding(sessionId)?.session
if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
const binding = ctx.sessions.binding(sessionId)
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
const session = binding.session
const chat = chatSource(binding)
return {
hooks: { transcriptView: transcriptView.mode },
keyedHooks: {
chatNode: key => chat.getSnapshot().nodes.source(key),
chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
},
openDetails: (target) => {
actions.select(target)
ctx.layout.openDetails()
@@ -1,21 +1,19 @@
import { memo, useCallback, useMemo } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatNodeStore } from '../contract/snapshot.ts'
import {
decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
type TurnProcessSpec,
} from '../contract/turn-process.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { storedTurnProcessEntry } from '../stores.ts'
import { useSearchableHidden } from './searchable-hidden.ts'
import css from './ChatView.module.css'
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly nodeKey: string
readonly useChatNode: ChatViewSlotProps['useChatNode']
readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
readonly historyIncomplete: boolean
readonly compactTranscript: boolean
readonly useChat: ChatViewSlotProps['useChat']
readonly useStore: ChatViewSlotProps['useStore']
readonly actions: ChatViewSlotProps['actions']
readonly renderSlot: ChatViewSlotProps['renderSlot']
@@ -26,133 +24,62 @@ type RoutedChatNodeOwner = {
[Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
}[ChatNode['kind']]
const EMPTY_PROCESS_KEYS: readonly string[] = []
interface TurnProcessLayout {
readonly hasExternalProcess: boolean
readonly compactAnswer: boolean
function turnDataOf(node: ChatNode | undefined): ConversationLocationDataStore<ConversationTurnDataMap> | undefined {
const location = node?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.data : undefined
}
function turnProcessOpeningHumanAnchor(
keys: readonly string[],
nodes: ChatNodeStore,
spec: TurnProcessSpec,
): number | undefined {
let anchor: number | undefined
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if ((node?.kind === 'user' || node?.kind === 'steering')
&& node.anchorSeq < spec.controlAnchorSeq) {
anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq)
}
}
return anchor
}
/** Derive disclosure facts from one content-revisioned Turn index. */
function turnProcessLayout(
keys: readonly string[],
nodes: ChatNodeStore,
spec: TurnProcessSpec,
): TurnProcessLayout {
let hasExternalProcess = false
let compactAnswer = true
const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec)
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if (node === undefined || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering')
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
&& (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
compactAnswer = false
}
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
|| node.anchorSeq < spec.processStartSeq
|| (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
hasExternalProcess = true
}
}
return { hasExternalProcess, compactAnswer }
function turnOf(node: ChatNode | undefined): number | undefined {
const location = node?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined
}
/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, historyIncomplete, compactTranscript,
nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
selectedCallId, cwd, openFile, inspectCall, forkAt,
renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
}: ChatNodeSeatProps) {
const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
const processSignature = useChat((snapshot) => {
const current = snapshot.nodes.get(nodeKey)
const location = current?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get('turn-process')
: undefined
})
const processSpec = useMemo(
() => processSignature === undefined ? undefined : decodeTurnProcess(processSignature),
[processSignature],
)
const nodeStore = useChat(snapshot => snapshot.nodes)
const processLayoutKeys = useChat((snapshot) => {
if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS
const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined
const location = current?.location
if (current === undefined
|| (location?.kind !== 'turn' && location?.kind !== 'step')
|| location.turn.status !== 'closed'
|| location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS
const ownsLayout = current.kind === 'turn-process'
|| (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep)
return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS
})
const processLayout = useMemo(
() => processSpec === undefined || processLayoutKeys.length === 0
? undefined
: turnProcessLayout(processLayoutKeys, nodeStore, processSpec),
[nodeStore, processLayoutKeys, processSpec],
)
const processGeneration = useMemo(
() => processSpec === undefined ? undefined : turnProcessGeneration(processSpec),
[processSpec],
)
const node = useChatNode(nodeKey)
const routedNode = node as ChatNode | undefined
const turn = turnOf(routedNode)
const processPresentation = useChatNodeProcess(nodeKey)
const processSpec = processPresentation?.spec
const storedEntry = useStore(state => processSpec === undefined
? undefined
: storedTurnProcessEntry(state, processSpec.turn))
const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined
const processEntry = processSpec !== undefined
&& processSpec.answerStep !== null
&& storedEntry?.answerStep === processSpec.answerStep
? storedEntry
: undefined
const processOpen = processEntry !== undefined
const setOpen = useCallback((open: boolean) => {
if (processGeneration !== undefined && processSpec !== undefined) {
actions.setTurnProcessOpen(processSpec.turn, processGeneration, open)
if (processSpec !== undefined && processSpec.answerStep !== null) {
actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open)
}
}, [actions, processGeneration, processSpec])
const routedNode = node as ChatNode | undefined
const sameTurn = routedNode !== undefined
&& processSpec !== undefined
&& (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step')
&& routedNode.location.turn.turn === processSpec.turn
const turnClosed = sameTurn
&& routedNode.location.turn.status === 'closed'
}, [actions, processSpec])
const processWindowReady = processSpec !== undefined
&& processPresentation !== undefined
&& compactTranscript
&& processSpec.answerAnchorSeq !== null
&& turnClosed
&& processPresentation.turn === processSpec.turn
&& processPresentation.turnClosed
&& !historyIncomplete
const processMember = sameTurn
const processMember = routedNode !== undefined
&& processWindowReady
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
&& routedNode.anchorSeq >= processSpec.processStartSeq
&& routedNode.anchorSeq < processSpec.answerAnchorSeq
const processAnswer = sameTurn
const processAnswer = routedNode !== undefined
&& processWindowReady
&& routedNode.kind === 'assistant-step'
&& routedNode.data.step === processSpec.answerStep
const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer
const foldable = processWindowReady
&& (processMember || (ownsDisclosure
&& ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning)))
const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined
&& (processPresentation.hasExternalProcess || processSpec.inlineReasoning)))
const turnProcess = useMemo(() => processSpec === undefined
? undefined
: {
spec: processSpec,
@@ -160,13 +87,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
open: processOpen,
setOpen,
}, [
foldable, processGeneration, processOpen, processSpec, setOpen,
foldable, processOpen, processSpec, setOpen,
])
const controllerInactive = routedNode?.kind === 'turn-process'
&& !foldable
const compactAnswer = processAnswer
&& foldable
&& processLayout?.compactAnswer === true
&& processPresentation.compactAnswer
&& !processOpen
const processHidden = controllerInactive || (foldable && processMember && !processOpen)
const revealProcess = useCallback(() => {
@@ -189,10 +116,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
renderMessageImages, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
const location = routedNode.location
const turn = location.kind === 'turn' || location.kind === 'step'
? location.turn.turn
: undefined
const turnData = turnDataOf(routedNode)
// Runtime dispatch owns the correlation: every Node's discriminant is the
// keyed-slot entry passed alongside that same Node. TypeScript does not
// distribute an object containing a union into a union of objects itself.
@@ -211,7 +135,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
>
{renderSlot('conversation.chat.node', routedOwner, {
entryKey: routedNode.kind,
hookContext: nodeKey,
hookContext: turnData,
fallback: (
<JsonBlock
label={t('message.unknownSurface', { type: routedNode.kind })}
@@ -1,7 +1,7 @@
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
// otherwise this view owns it. Each row subscribes to one stable node key.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
import type {
ConversationTimelineSnapshot, RenderMessageImages,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -17,6 +17,7 @@ import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
const SCROLL_SAMPLE_INTERVAL_MS = 500
/** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement {
@@ -199,13 +200,24 @@ function TurnStatus({ startTime, t }: {
)
}
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey'> & {
readonly order: readonly string[]
}
const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) {
return order.map(nodeKey => (
<ChatNodeSeat key={nodeKey} nodeKey={nodeKey} {...seatProps} />
))
})
/**
* The chat view slot entry: pure component over the composed props; each
* ordered business Node crosses the keyed renderer seat.
*/
export function ChatView({
useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough,
loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t,
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
useTranscriptView, useProjection, t,
}: ChatViewSlotProps) {
const order = useChat(s => s.order)
const nodeStore = useChat(s => s.nodes)
@@ -296,6 +308,8 @@ export function ChatView({
// restores it and normalizes a floor-clamped position back to following.
const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
const atBottomRef = useRef(atBottom)
const scrollSamplePendingRef = useRef(false)
const [, setScrollSampleTick] = useState(0)
const [activeTurn, setActiveTurn] = useState<number | null>(
() => turnNavigationItems.at(-1)?.turn ?? null,
)
@@ -332,6 +346,7 @@ export function ChatView({
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}`
const syncActiveTurn = useCallback((): void => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
const first = turnNavigationItems[0]
if (local === null || first === undefined) {
@@ -450,6 +465,7 @@ export function ChatView({
}
useLayoutEffect(() => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
@@ -563,18 +579,33 @@ export function ChatView({
scheduleActiveTurn()
}
// Bind the scroll listener on the resolved scrollport once per mount;
// reader-input attribution rides the observed-top ledger, not per-device
// input listeners.
// Raw scroll events only schedule work. Geometry is sampled at most once
// per interval, with scrollend providing the final sample for a short burst.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
let sampleTimer: number | undefined
const sample = (): void => {
if (!scrollSamplePendingRef.current) return
scrollSamplePendingRef.current = false
if (sampleTimer !== undefined) window.clearTimeout(sampleTimer)
sampleTimer = undefined
onScrollRef.current()
setScrollSampleTick(tick => tick + 1)
}
const onScroll = (): void => {
scrollSamplePendingRef.current = true
sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS)
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('scrollend', sample, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('scrollend', sample)
if (sampleTimer !== undefined) window.clearTimeout(sampleTimer)
scrollSamplePendingRef.current = false
}
}, [])
@@ -582,6 +613,7 @@ export function ChatView({
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
if (local !== null && atBottomRef.current) {
const el = scrollerOf(local)
@@ -742,26 +774,24 @@ export function ChatView({
</button>
</div>
)}
{order.map(nodeKey => (
<ChatNodeSeat
key={nodeKey}
nodeKey={nodeKey}
historyIncomplete={hasMore}
compactTranscript={compactTranscript}
useChat={useChat}
useStore={useStore}
actions={actions}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={requestOpenFile}
inspectCall={inspectCall}
forkAt={forkAt}
renderMessageImages={renderMessageImages}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
))}
<ChatNodeList
order={order}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
historyIncomplete={hasMore}
compactTranscript={compactTranscript}
useStore={useStore}
actions={actions}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={requestOpenFile}
inspectCall={inspectCall}
forkAt={forkAt}
renderMessageImages={renderMessageImages}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
@@ -30,19 +30,26 @@
white-space: nowrap;
}
/* Row containers (user rows and turn tails) opt in with
data-actions-reveal='hover': every row but the latest of its kind reveals
the entire actions row (icons and text) on hover or focus-within, while the
latest ('always') keeps its row visible — no rule matches it. Opacity keeps
the layout stable, and devices without hover keep the row visible. */
/* Earlier user-authored flow rows and opted-in turn tails reveal the complete
actions row on hover or focus-within. Opacity keeps layout stable, and
devices without hover keep every row visible. */
@media (hover: hover) {
[data-actions-reveal='hover'] .actions {
[data-actions-reveal='hover'] .actions,
:is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering']):has(
~ :is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering'])
) .actions {
opacity: 0;
transition: opacity 80ms ease;
}
[data-actions-reveal='hover']:hover .actions,
[data-actions-reveal='hover']:focus-within .actions {
[data-actions-reveal='hover']:focus-within .actions,
:is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering']):has(
~ :is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering'])
):hover .actions,
:is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering']):has(
~ :is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering'])
):focus-within .actions {
opacity: 1;
}
}
@@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t,
content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
}: {
content: readonly unknown[]
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -162,8 +162,6 @@ function UserStyleBubble({
referenceLabels?: readonly string[]
/** Local submission-echo previews replacing the content-derived image group. */
previewImages?: readonly MessageImageSource[]
/** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */
reveal?: 'always' | 'hover'
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, images: contentImages, rest } = contentParts(content)
@@ -175,7 +173,6 @@ function UserStyleBubble({
className={css.userRow}
data-pending-steering={pending || undefined}
data-submission-echo={echo || undefined}
data-actions-reveal={reveal}
>
<div className={css.userStack}>
{renderMessageImages({ images, align: 'end' })}
@@ -274,24 +271,14 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, renderMessageImages, useChat, t,
node, renderMessageImages, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
// The transcript's last user-authored row keeps its actions row shown, the
// same recency gate turn tails use; earlier rows reveal on hover.
const isLatestUserRow = useChat((snapshot) => {
for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
const candidate = snapshot.nodes.get(snapshot.order[index] ?? '')
if (candidate?.kind === 'user' || candidate?.kind === 'steering') return candidate.key === node.key
}
return true
})
return (
<UserStyleBubble
content={data.content}
renderMessageImages={renderMessageImages}
{...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
reveal={isLatestUserRow ? 'always' : 'hover'}
t={t}
actions={text => (
<MessageIconActions
@@ -3,6 +3,11 @@
flex-direction: column;
}
.root:not([data-expanded]) {
contain: size layout;
height: calc(24px + var(--dsh-content-font-delta, 0px));
}
.row {
position: relative;
overflow: hidden;
@@ -60,11 +65,26 @@
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.summaryText {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.summary[data-follow-end] {
display: flex;
justify-content: flex-end;
}
.summary[data-follow-end] .summaryText {
flex: 0 0 auto;
width: max-content;
min-width: 100%;
overflow: visible;
text-align: start;
text-overflow: clip;
}
@@ -1,8 +1,7 @@
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { DisclosureRow, IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
import a11yCss from './accessibility.module.css'
import css from './ReasoningRow.module.css'
@@ -26,19 +25,15 @@ function latestLine(text: string): string {
*/
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
const [expanded, setExpanded] = useState(false)
const summaryRef = useRef<HTMLSpanElement>(null)
const summary = running ? latestLine(text) : firstLine(text)
const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
const element = summaryRef.current
if (element === null) return
element.scrollLeft = running ? element.scrollWidth - element.clientWidth : 0
})
useEffect(() => {
scheduleSummaryScroll()
}, [running, scheduleSummaryScroll, summary])
return (
<div className={css.root} data-variant="think" data-state={running ? 'running' : 'ok'}>
<div
className={css.root}
data-variant="think"
data-state={running ? 'running' : 'ok'}
data-expanded={expanded || undefined}
>
{running && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
<DisclosureRow
rowClassName={css.row}
@@ -54,7 +49,9 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span ref={summaryRef} className={css.summary} data-follow-end={running || undefined}>{summary}</span>
<span className={css.summary} data-follow-end={running || undefined}>
<span className={css.summaryText}>{summary}</span>
</span>
</>
)}
>
@@ -2,7 +2,7 @@
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
@@ -122,6 +122,44 @@ export interface StatsLineProps {
t: ChatViewSlotProps['t']
}
/** Render and measure one non-empty statistics line. */
const StatsLineContent = memo(function StatsLineContent({
groups,
line,
}: {
readonly groups: readonly string[]
readonly line: string
}) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
const measure = useCallback(() => {
const el = rootRef.current
if (el === null) return
const next = el.scrollWidth > el.clientWidth
setTruncated(current => current === next ? current : next)
}, [])
useLayoutEffect(() => {
const el = rootRef.current
if (el === null || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [measure])
useLayoutEffect(measure, [line, measure])
return (
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
})
export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) {
const settledNodes = useChat(s => s.legacy.nodes)
const usage = useProjection('tokenUsage')
@@ -166,31 +204,6 @@ export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }:
}))
}
const line = groups.join(' | ')
// The row elides with ellipsis when overlong; a delayed hover tooltip carries
// the full line, enabled only while content is actually clipped.
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = rootRef.current
if (el === null) return
const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [line])
if (groups.length === 0) return null
return (
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
return <StatsLineContent groups={groups} line={line} />
})
@@ -1,40 +0,0 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
* @param update - DOM alignment to run after the throttle interval.
* @param intervalFrames - frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
update: () => void,
intervalFrames = DEFAULT_INTERVAL_FRAMES,
): () => void {
const updateRef = useRef(update)
updateRef.current = update
const pendingFrameRef = useRef<number | null>(null)
useLayoutEffect(() => () => {
if (pendingFrameRef.current === null) return
cancelAnimationFrame(pendingFrameRef.current)
pendingFrameRef.current = null
}, [])
return useCallback(() => {
if (pendingFrameRef.current !== null) return
let remainingFrames = intervalFrames
const advance = (): void => {
remainingFrames -= 1
if (remainingFrames > 0) {
pendingFrameRef.current = requestAnimationFrame(advance)
return
}
pendingFrameRef.current = null
updateRef.current()
}
pendingFrameRef.current = requestAnimationFrame(advance)
}, [intervalFrames])
}
@@ -0,0 +1,23 @@
import { useSyncExternalStore } from 'react'
import type {
ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
const EMPTY_SOURCE: ConversationLocationDataSource<undefined> = {
getSnapshot: () => undefined,
subscribe: () => () => {},
}
/**
* Subscribe to one value from a Turn's keyed Location-data store.
* @param data - current Turn data store, or absence for a Node outside a Turn.
* @param key - declaration-merged business key.
* @returns the current value for that key.
*/
export function useTurnDataValue<Key extends Extract<keyof ConversationTurnDataMap, string>>(
data: ConversationLocationDataStore<ConversationTurnDataMap> | undefined,
key: Key,
): Readonly<ConversationTurnDataMap[Key]> | undefined {
const source = data?.source(key) ?? EMPTY_SOURCE
return useSyncExternalStore(source.subscribe, source.getSnapshot)
}
@@ -2,25 +2,35 @@
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type {
ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
ConversationLocationDataStore, ConversationTurnDataMap,
MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
SnapshotSelectorHook,
InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { createChatStore } from '../stores.ts'
import type { ToolCallId, SelectionTarget } from './store.ts'
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type {
ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode,
CompactionSummaryNode, ToolCallBlock,
} from './snapshot.ts'
import type { TurnProcessSpec } from './turn-process.ts'
import type { TranscriptViewMode } from '../../chat-settings.ts'
/** Selector hook over the current Conversation binding's Chat target. */
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
/** Per-key selector hook over one Chat Node. */
export type UseChatNode = KeyedSnapshotSelectorHook<ChatConversationViewNode | undefined>
/** Per-key selector hook over one Chat Node's Turn-process presentation. */
export type UseChatNodeProcess = KeyedSnapshotSelectorHook<ChatTurnProcessPresentation | undefined>
/** Owner currency of the completed-Turn extension chain. */
export interface TurnTailOwnerProps {
turn: TurnLocation
@@ -116,6 +126,12 @@ export interface ChatViewInjected {
/** Persisted completed-Turn transcript presentation. */
transcriptView: SnapshotStore<TranscriptViewMode>
}
keyedHooks: {
/** Resolve the stable source for one Chat Node key. */
chatNode: (key: string) => ChatNodeSource
/** Resolve the stable Turn-process source for one Chat Node key. */
chatNodeProcess: (key: string) => ChatNodeProcessSource
}
openDetails: (target: SelectionTarget) => void
openFile: (path: string) => Promise<void>
loadOlder: () => void
@@ -176,7 +192,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
scope: 'session'
owner: ChatNodeOwnerProps
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
hookContext: string
hookContext: ConversationLocationDataStore<ConversationTurnDataMap> | undefined
inject: ChatNodeTurnDataInjected
}
/**
@@ -2,6 +2,7 @@ import type {
ConversationNode, ConversationTimelineSnapshot, PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode } from './chat-nodes.ts'
import type { TurnProcessSpec } from './turn-process.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
@@ -10,10 +11,31 @@ export type {
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Per-key observable used by one mounted Chat Node Seat. */
export interface ChatNodeSource {
/** @returns the current Node for this source's stable key. */
readonly getSnapshot: () => ChatConversationViewNode | undefined
/** @param listener - callback for changes to this key. @returns the unsubscribe function. */
readonly subscribe: (listener: () => void) => () => void
}
/** Per-key observable for the Turn-process presentation surrounding one Chat Node. */
export interface ChatNodeProcessSource {
/** @returns the current presentation, or absence outside a projected Turn process. */
readonly getSnapshot: () => ChatTurnProcessPresentation | undefined
/** @param listener - callback for presentation changes. @returns the unsubscribe function. */
readonly subscribe: (listener: () => void) => () => void
}
/** Stable live per-key reader for Chat nodes. */
export interface ChatNodeStore {
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
get(key: string): ChatConversationViewNode | undefined
/** @param key - stable Conversation Context key. @returns its identity-stable observable source. */
source(key: string): ChatNodeSource
/** @param key - stable Conversation Context key. @returns its Turn-process presentation source. */
processSource(key: string): ChatNodeProcessSource
/** @returns all currently materialized Nodes without imposing render order. */
values(): readonly ChatConversationViewNode[]
}
@@ -48,6 +70,15 @@ export interface ChatLocationNodeIndex {
getStep(turn: number, step: number): readonly string[]
}
/** Cross-Node presentation facts derived for one Turn process. */
export interface ChatTurnProcessPresentation {
readonly turn: number
readonly spec: TurnProcessSpec
readonly turnClosed: boolean
readonly hasExternalProcess: boolean
readonly compactAnswer: boolean
}
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
@@ -75,12 +106,22 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
const EMPTY_NODE_SOURCE: ChatNodeSource = {
getSnapshot: () => undefined,
subscribe: () => () => {},
}
const EMPTY_NODE_PROCESS_SOURCE: ChatNodeProcessSource = {
getSnapshot: () => undefined,
subscribe: () => () => {},
}
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
nodes: {
get: () => undefined,
source: () => EMPTY_NODE_SOURCE,
processSource: () => EMPTY_NODE_PROCESS_SOURCE,
values: () => EMPTY_LIST,
},
locations: {
@@ -1,7 +1,5 @@
/** Chat-owned selection state shared by the transcript and details panel. */
import type { TurnProcessGeneration } from './turn-process.ts'
/** Tool call identity as carried by Chat nodes. */
export type ToolCallId = string
@@ -16,7 +14,7 @@ export interface SelectionTarget {
/** One manually expanded Turn answer generation. */
export interface TurnProcessViewEntry {
readonly turn: number
readonly generation: TurnProcessGeneration
readonly answerStep: number
}
/** Per-Session state shared only by the Chat view and details surface. */
@@ -1,11 +1,5 @@
import type { ChatNode } from './chat-nodes.ts'
/** Turn-local process window encoded as a reference-stable Location-data scalar. */
export type TurnProcessSignature = string
/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */
export type TurnProcessGeneration = string
/** Current process range and finalized answer boundary derived from one Turn. */
export interface TurnProcessSpec {
readonly turn: number
@@ -39,54 +33,21 @@ export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet<string> = new Set(
)
/**
* Identify one finalized answer generation without using its ordering anchor.
* @param spec - current Turn process specification.
* @returns stable identity until the finalized answer Step is withdrawn or replaced.
* Compare immutable Turn-process specifications by their published fields.
* @param left - previous specification.
* @param right - next specification.
* @returns whether both values describe the same process presentation.
*/
export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration {
return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}`
}
/**
* Encode one process specification as a primitive Location-data value.
* @param spec - current Turn process specification.
* @returns reference-stable scalar for equal specifications.
*/
export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature {
return [
spec.turn,
spec.controlAnchorSeq,
spec.processStartSeq,
spec.answerAnchorSeq ?? '',
spec.answerStep ?? '',
spec.inlineReasoning ? 1 : 0,
spec.messageCount,
spec.toolCallCount,
spec.subagentCount,
].join('|')
}
/**
* Decode a same-process signature produced by {@link encodeTurnProcess}.
* @param signature - encoded Turn process value.
* @returns decoded process specification.
*/
export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec {
const [
turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning,
messageCount, toolCallCount, subagentCount,
] = signature.split('|')
return {
turn: Number(turn),
controlAnchorSeq: Number(controlAnchorSeq),
processStartSeq: Number(processStartSeq),
answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq),
answerStep: answerStep === '' ? null : Number(answerStep),
inlineReasoning: inlineReasoning === '1',
messageCount: Number(messageCount),
toolCallCount: Number(toolCallCount),
subagentCount: Number(subagentCount),
}
export function sameTurnProcessSpec(left: TurnProcessSpec, right: TurnProcessSpec): boolean {
return left.turn === right.turn
&& left.controlAnchorSeq === right.controlAnchorSeq
&& left.processStartSeq === right.processStartSeq
&& left.answerAnchorSeq === right.answerAnchorSeq
&& left.answerStep === right.answerStep
&& left.inlineReasoning === right.inlineReasoning
&& left.messageCount === right.messageCount
&& left.toolCallCount === right.toolCallCount
&& left.subagentCount === right.subagentCount
}
/**
@@ -364,6 +364,13 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
}
}
function publishedAssistantData(
context: ConversationNodeContext<AssistantState>,
): Readonly<AssistantChatData> | undefined {
const location = context.start?.location ?? context.matches.at(-1)?.location
return location?.kind === 'step' ? location.step.data.get('assistant-step') : undefined
}
/** Per-step Assistant streaming/final/interruption Definition. */
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
kind: 'assistant-step',
@@ -427,16 +434,19 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
}
},
buildViewNode: (context) => {
const projected = projectAssistant(context)
if (projected === undefined) return null
if (projected.settled === undefined && !projected.visible) {
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const data = publishedAssistantData(context)
if (data === undefined) return null
const settled = data.finalNode
const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(data.blocks)
if (settled === undefined && !visible) {
const current = context.current.get('chat')
if (!state.hidden || current === undefined || current === null) return null
}
return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, {
visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden',
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
return chatNode(context, 'assistant-step', anchorSeq, data, {
visibility: settled?.interrupted === true || visible ? 'visible' : 'hidden',
})
},
}
@@ -1,4 +1,5 @@
import type { Context } from '@deepseek-ai/cordis'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {
ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
ConversationViewDefinition,
@@ -6,12 +7,14 @@ import type {
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot,
ChatTurnNavigationIndex, ChatTurnProcessPresentation, ConversationNode, LegacyConversationSlice,
PartialAssistant, RunningToolCall, TurnNavigationItem,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { sessionRecallLabels } from './event-projection.ts'
import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
import { ChatTurnProcessProjector } from './turn-process-presentation.ts'
const EMPTY_KEYS: readonly string[] = []
const EMPTY_TURNS: readonly number[] = []
@@ -22,8 +25,54 @@ function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function cachedSource<Key, Source>(
sources: Map<Key, Source>,
key: Key,
create: () => Source,
): Source {
let source = sources.get(key)
if (source === undefined) {
source = create()
sources.set(key, source)
}
return source
}
/* jscpd:ignore-start -- Chat Node sources keep publication state inside the keyed Chat store. */
class MutableChatSource<Value> {
private readonly listeners = new Set<() => void>()
private published: Value
constructor(
private readonly read: () => Value,
private readonly label: string,
) {
this.published = read()
}
readonly getSnapshot = (): Value => this.read()
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
publish(): void {
const next = this.getSnapshot()
if (this.published === next) return
this.published = next
notifySubscribers(this.listeners, this.label)
}
}
/* jscpd:ignore-end */
class MutableChatNodeStore implements ChatNodeStore {
private readonly byKey = new Map<string, ChatConversationViewNode>()
private readonly turnProcesses = new ChatTurnProcessProjector()
private readonly sources = new Map<string, MutableChatSource<ChatConversationViewNode | undefined>>()
private readonly processSources = new Map<string, MutableChatSource<ChatTurnProcessPresentation | undefined>>()
private readonly dirtyKeys = new Set<string>()
private readonly dirtyProcessKeys = new Set<string>()
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
private valuesDirty = false
@@ -31,6 +80,24 @@ class MutableChatNodeStore implements ChatNodeStore {
return this.byKey.get(key)
}
source(key: string): ChatNodeSource {
return cachedSource(this.sources, key, () => new MutableChatSource(
() => this.get(key),
`[ui-chat] node source ${key}`,
))
}
processSource(key: string): ChatNodeProcessSource {
return cachedSource(this.processSources, key, () => new MutableChatSource(
() => this.process(key),
`[ui-chat] node process source ${key}`,
))
}
process(key: string): ChatTurnProcessPresentation | undefined {
return this.turnProcesses.get(this.get(key) as ChatNode | undefined)
}
values(): readonly ChatConversationViewNode[] {
if (this.valuesDirty) {
this.valuesCache = [...this.byKey.values()]
@@ -40,8 +107,20 @@ class MutableChatNodeStore implements ChatNodeStore {
}
replace(nodes: readonly ChatConversationViewNode[]): void {
const previous = new Map(this.byKey)
this.byKey.clear()
for (const node of nodes) this.byKey.set(node.key, node)
for (const node of nodes) {
this.byKey.set(node.key, node)
if (previous.get(node.key) !== node) {
this.dirtyKeys.add(node.key)
this.dirtyProcessKeys.add(node.key)
}
previous.delete(node.key)
}
for (const key of previous.keys()) {
this.dirtyKeys.add(key)
this.dirtyProcessKeys.add(key)
}
this.valuesCache = [...this.byKey.values()]
this.valuesDirty = false
}
@@ -51,10 +130,35 @@ class MutableChatNodeStore implements ChatNodeStore {
for (const node of nodes) {
if (this.byKey.get(node.key) === node) continue
this.byKey.set(node.key, node)
this.dirtyKeys.add(node.key)
this.dirtyProcessKeys.add(node.key)
changed = true
}
if (changed) this.valuesDirty = true
}
touchProcesses(turns: ReadonlySet<number>, locations: ChatLocationNodeIndex): void {
for (const turn of turns) {
for (const key of locations.getTurn(turn)) this.dirtyProcessKeys.add(key)
}
}
replaceProcesses(order: readonly string[], locations: ChatLocationNodeIndex): void {
this.touchProcesses(this.turnProcesses.replace(order, locations, this), locations)
}
updateProcesses(turns: ReadonlySet<number>, locations: ChatLocationNodeIndex): void {
this.touchProcesses(this.turnProcesses.update(turns, locations, this), locations)
}
publish(): void {
const dirty = [...this.dirtyKeys]
const dirtyProcesses = [...this.dirtyProcessKeys]
this.dirtyKeys.clear()
this.dirtyProcessKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
for (const key of dirtyProcesses) this.processSources.get(key)?.publish()
}
}
class MutableChatLocationIndex implements ChatLocationNodeIndex {
@@ -193,6 +297,25 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s
return {}
}
function locationTurnStatus(location: ConversationLocation): string | undefined {
return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined
}
function processPresentationInputChanged(
previous: ChatNode | undefined,
next: ChatNode,
structural: boolean,
): boolean {
if (structural || previous === undefined) return true
if (locationTurnStatus(previous.location) !== locationTurnStatus(next.location)) return true
if (previous.kind === 'turn-process' && next.kind === 'turn-process') {
return previous.data !== next.data
}
return previous.kind === 'assistant-step'
&& next.kind === 'assistant-step'
&& previous.data.step !== next.data.step
}
interface TurnProcessPresentation {
readonly control?: ChatNode<'turn-process'>
readonly openingHumanAnchor?: number
@@ -650,9 +773,12 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.store.replace(nodes)
this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
this.locations.rebuild(this.order, this.store)
this.store.replaceProcesses(this.order, this.locations)
this.navigation.rebuild(input.timeline, this.locations, this.store)
this.timeline = input.timeline
return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
this.store.publish()
return snapshot
}
apply(input: {
@@ -660,6 +786,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
readonly timeline: ConversationTimelineSnapshot
}): ChatSnapshot {
const upserts = this.referenceLabels.apply(input.upserts, this.store)
const processTurns = new Set<number>()
let structural = false
const contentOnly: ChatConversationViewNode[] = []
for (const node of upserts) {
@@ -671,6 +798,12 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|| locationIdentity(previous.location) !== locationIdentity(node.location)
structural ||= nodeStructural
if (!nodeStructural) contentOnly.push(node)
if (processPresentationInputChanged(previous as ChatNode | undefined, node as ChatNode, nodeStructural)) {
const previousTurn = previous === undefined ? undefined : locationCoordinates(previous.location).turn
const nextTurn = locationCoordinates(node.location).turn
if (previousTurn !== undefined) processTurns.add(previousTurn)
if (nextTurn !== undefined) processTurns.add(nextTurn)
}
}
this.store.upsert(upserts)
if (structural) {
@@ -679,13 +812,16 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.locations.rebuild(this.order, this.store)
}
this.locations.touch(contentOnly)
this.store.updateProcesses(processTurns, this.locations)
if (structural || input.timeline !== this.timeline) {
this.navigation.rebuild(input.timeline, this.locations, this.store)
} else {
this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
}
this.timeline = input.timeline
return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
this.store.publish()
return snapshot
}
private snapshot(
@@ -0,0 +1,139 @@
import type { ChatNode } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
function nodeTurn(node: ChatNode | undefined): number | undefined {
const location = node?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined
}
function samePresentation(
left: ChatTurnProcessPresentation | undefined,
right: ChatTurnProcessPresentation | undefined,
): boolean {
return left === right || (left !== undefined && right !== undefined
&& left.spec === right.spec
&& left.turn === right.turn
&& left.turnClosed === right.turnClosed
&& left.hasExternalProcess === right.hasExternalProcess
&& left.compactAnswer === right.compactAnswer)
}
function derivePresentation(
turn: number,
locations: ChatLocationNodeIndex,
nodes: ChatNodeStore,
): ChatTurnProcessPresentation | undefined {
const keys = locations.getTurn(turn)
const control = keys
.map(key => nodes.get(key) as ChatNode | undefined)
.find((node): node is ChatNode<'turn-process'> => node?.kind === 'turn-process')
if (control === undefined) return undefined
const spec = control.data
const location = control.location
if (location.kind !== 'turn' && location.kind !== 'step') return undefined
let openingHumanAnchor: number | undefined
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if ((node?.kind === 'user' || node?.kind === 'steering')
&& node.anchorSeq < spec.controlAnchorSeq) {
openingHumanAnchor = Math.min(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq)
}
}
let hasExternalProcess = false
let compactAnswer = true
for (const key of keys) {
const node = nodes.get(key) as ChatNode | undefined
if (node === undefined || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering')
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
&& (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
compactAnswer = false
}
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
|| node.anchorSeq < spec.processStartSeq
|| (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
hasExternalProcess = true
}
}
return {
turn,
spec,
turnClosed: location.turn.status === 'closed',
hasExternalProcess,
compactAnswer,
}
}
/** Mutable projection of cross-Node process layout facts by Turn. */
export class ChatTurnProcessProjector {
private presentations = new Map<number, ChatTurnProcessPresentation>()
/**
* Read the retained process presentation for a Node's Turn.
* @param node - Current Chat Node.
* @returns The Turn's process presentation, when present.
*/
get(node: ChatNode | undefined): ChatTurnProcessPresentation | undefined {
const turn = nodeTurn(node)
return turn === undefined ? undefined : this.presentations.get(turn)
}
/**
* Replace every projected Turn.
* @param order - visible Chat Node order.
* @param locations - current Chat Location index.
* @param nodes - current Chat Node store.
* @returns Turns whose process presentation changed.
*/
replace(
order: readonly string[],
locations: ChatLocationNodeIndex,
nodes: ChatNodeStore,
): ReadonlySet<number> {
const turns = new Set<number>()
for (const key of order) {
const turn = nodeTurn(nodes.get(key) as ChatNode | undefined)
if (turn !== undefined) turns.add(turn)
}
const changed = new Set<number>()
for (const turn of new Set([...this.presentations.keys(), ...turns])) {
if (this.set(turn, turns.has(turn) ? derivePresentation(turn, locations, nodes) : undefined)) {
changed.add(turn)
}
}
return changed
}
/**
* Recompute selected Turns after incremental Node changes.
* @param turns - affected Turn numbers.
* @param locations - current Chat Location index.
* @param nodes - current Chat Node store.
* @returns Turns whose process presentation changed.
*/
update(
turns: ReadonlySet<number>,
locations: ChatLocationNodeIndex,
nodes: ChatNodeStore,
): ReadonlySet<number> {
const changed = new Set<number>()
for (const turn of turns) {
if (this.set(turn, derivePresentation(turn, locations, nodes))) changed.add(turn)
}
return changed
}
private set(turn: number, next: ChatTurnProcessPresentation | undefined): boolean {
const current = this.presentations.get(turn)
if (samePresentation(current, next)) return false
if (next === undefined) this.presentations.delete(turn)
else this.presentations.set(turn, next)
return true
}
}
@@ -7,10 +7,9 @@ import type {} from '@deepseek-ai/dsh-llm-retry/types'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {} from '@deepseek-ai/dsh-tools/types'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts'
import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts'
import {
decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool,
type TurnProcessSignature, type TurnProcessSpec,
isSubagentDelegationTool, sameTurnProcessSpec, type TurnProcessSpec,
} from '../contract/turn-process.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
import { toAssistantBlocks } from './event-projection.ts'
@@ -24,8 +23,8 @@ declare module '../contract/chat-nodes.ts' {
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationTurnDataMap {
/** Encoded process range and finalized answer boundary for this Turn. */
'turn-process': TurnProcessSignature
/** Process range and finalized answer boundary for this Turn. */
'turn-process': TurnProcessSpec
}
}
@@ -34,6 +33,8 @@ interface TurnProcessState {
readonly assistantStartByStep: ReadonlyMap<number, number>
readonly messageCountByStep: ReadonlyMap<number, number>
readonly otherStartSeq?: number
readonly controlAnchorSeq?: number
readonly messageCount: number
readonly toolCallCount: number
readonly subagentCount: number
}
@@ -109,6 +110,7 @@ function fallbackState(context: ConversationNodeContext<TurnProcessState>): Turn
turn,
assistantStartByStep: new Map(),
messageCountByStep: new Map(),
messageCount: 0,
toolCallCount: 0,
subagentCount: 0,
}
@@ -130,15 +132,12 @@ function latestAnswer(turn: TurnLocation): Readonly<FinalAssistantChatData> | nu
}
function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null {
const controlAnchorSeq = Math.min(
state.otherStartSeq ?? Number.POSITIVE_INFINITY,
...state.assistantStartByStep.values(),
)
if (!Number.isFinite(controlAnchorSeq)) return null
const controlAnchorSeq = state.controlAnchorSeq
if (controlAnchorSeq === undefined) return null
const answer = latestAnswer(turn)
const counts = {
messageCount: answer === null
? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0)
? state.messageCount
: [...state.messageCountByStep]
.filter(([step]) => step < answer.step)
.reduce((total, [, count]) => total + count, 0),
@@ -185,7 +184,7 @@ function updateProcessState(state: TurnProcessState, event: ConversationEvent):
&& hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) {
const messageCountByStep = new Map(current.messageCountByStep)
messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1)
current = { ...current, messageCountByStep }
current = { ...current, messageCountByStep, messageCount: current.messageCount + 1 }
}
if (event.type === 'tool/call') {
const subagent = isSubagentDelegationTool(event.data.name)
@@ -198,12 +197,22 @@ function updateProcessState(state: TurnProcessState, event: ConversationEvent):
const evidence = processEvidence(event)
if (evidence === undefined) return current
if (evidence.kind === 'other') {
return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current
return current.otherStartSeq === undefined
? {
...current,
otherStartSeq: evidence.seq,
controlAnchorSeq: Math.min(current.controlAnchorSeq ?? Number.POSITIVE_INFINITY, evidence.seq),
}
: current
}
if (current.assistantStartByStep.has(evidence.step)) return current
const assistantStartByStep = new Map(current.assistantStartByStep)
assistantStartByStep.set(evidence.step, evidence.seq)
return { ...current, assistantStartByStep }
return {
...current,
assistantStartByStep,
controlAnchorSeq: Math.min(current.controlAnchorSeq ?? Number.POSITIVE_INFINITY, evidence.seq),
}
}
/** Turn-scoped process range and answer-boundary Definition. */
@@ -233,6 +242,7 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
turn: match.event.data.turn,
assistantStartByStep: new Map(),
messageCountByStep: new Map(),
messageCount: 0,
toolCallCount: 0,
subagentCount: 0,
}
@@ -246,25 +256,53 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
}
return 'immediate'
},
buildLocationData: (context, scope) => {
buildLocationData: (context, scope, previous) => {
if (scope !== 'turn') return null
const state = context.state ?? fallbackState(context)
if (state === undefined) return null
const turn = turnLocation(context)
if (turn === undefined) return null
const current = context.current.get('chat') as ChatNode | null | undefined
const latestStep = turn.steps.at(-1)
if (previous?.kind === 'turn'
&& previous.key === 'turn-process'
&& current?.kind === 'turn-process'
&& current.data.answerAnchorSeq === null
&& current.data.controlAnchorSeq === state.controlAnchorSeq
&& current.data.messageCount === state.messageCount
&& current.data.toolCallCount === state.toolCallCount
&& current.data.subagentCount === state.subagentCount
&& turn.status !== 'closed'
&& latestStep?.status !== 'closed') return previous
const spec = processSpec(state, turn)
return spec === null ? null : {
if (spec === null) return null
if (previous?.kind === 'turn'
&& previous.turn === spec.turn
&& previous.key === 'turn-process'
&& sameTurnProcessSpec(previous.value, spec)) return previous
return {
kind: 'turn',
turn: turn.turn,
key: 'turn-process',
value: encodeTurnProcess(spec),
value: spec,
}
},
buildViewNode: (context) => {
const turn = turnLocation(context)
const signature = turn?.data.get('turn-process')
if (turn === undefined || signature === undefined) return null
const data = decodeTurnProcess(signature)
const data = turn?.data.get('turn-process')
if (turn === undefined || data === undefined) return null
const current = context.current.get('chat') as ChatNode | null | undefined
const state = context.state
if (current?.kind === 'turn-process'
&& state !== undefined
&& current.data.answerAnchorSeq === null
&& current.data.controlAnchorSeq === state.controlAnchorSeq
&& current.data.messageCount === state.messageCount
&& current.data.toolCallCount === state.toolCallCount
&& current.data.subagentCount === state.subagentCount
&& turn.status !== 'closed'
&& turn.steps.at(-1)?.status !== 'closed'
&& current.location === (context.start?.location ?? context.matches[0]?.location)) return current
return chatNode(
context,
'turn-process',
@@ -134,8 +134,9 @@ function hasText(data: AssistantChatData): data is FinalAssistantChatData {
}
function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChatData | null {
const end = context.state?.end
?? context.matches.find(match => match.event.type === 'turn/end')
const end = context.state === undefined
? context.matches.find(match => match.event.type === 'turn/end')
: context.state.end
if (end?.event.type !== 'turn/end') return null
const turn = turnLocation(context)
if (turn === undefined) return null
+3 -2
View File
@@ -15,7 +15,8 @@ export type {} from './conversation-nodes/turn-tail.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex,
AssistantTiming, ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore,
ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation,
CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall,
SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
@@ -37,7 +38,7 @@ export type {
TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
} from './contract/slots.ts'
export type {
TurnProcessGeneration, TurnProcessSignature, TurnProcessSpec,
TurnProcessSpec,
} from './contract/turn-process.ts'
export type { ChatKey } from './locale.ts'
export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'
+4 -5
View File
@@ -1,6 +1,5 @@
/** Per-Session Chat selection store shared by the transcript and details panel. */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type { TurnProcessGeneration } from './contract/turn-process.ts'
import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts'
type ChatActions = {
@@ -8,13 +7,13 @@ type ChatActions = {
setTurnProcessOpen: (
draft: ChatStoreState,
turn: number,
generation: TurnProcessGeneration,
answerStep: number,
open: boolean,
) => void
}
/**
* Resolve any stored generation for one Turn.
* Resolve the manually expanded answer for one Turn.
* @param state - Chat store snapshot.
* @param turn - owning Turn.
* @returns the Turn's stored entry, when present.
@@ -35,13 +34,13 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }),
actions: {
select: (draft, target: SelectionTarget | null) => { draft.selection = target },
setTurnProcessOpen: (draft, turn, generation, open) => {
setTurnProcessOpen: (draft, turn, answerStep, open) => {
const index = draft.turnProcesses.findIndex(entry => entry.turn === turn)
if (!open) {
if (index >= 0) draft.turnProcesses.splice(index, 1)
return
}
const next = { turn, generation } satisfies TurnProcessViewEntry
const next = { turn, answerStep } satisfies TurnProcessViewEntry
if (index < 0) draft.turnProcesses.push(next)
else draft.turnProcesses[index] = next
},
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import {
chatSnapshot, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -11,11 +12,14 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
apply as applyConversation, inject as injectConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {
ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChat,
ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChatNodeTurnData,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
@@ -149,37 +153,51 @@ describe('Chat apply wiring', () => {
await b.runtime.dispose()
})
it('binds Turn data through the Chat selector hook for Turn and Step locations', async () => {
it('binds Turn data directly to its keyed Location source', async () => {
const b = await bench()
const spec = b.runtime.slots.spec('conversation.chat.node') as unknown as {
inject: ChatNodeTurnDataInjected
}
let snapshot: ChatSnapshot = chatSnapshot()
const useChat = ((selector: (value: ChatSnapshot) => unknown) => selector(snapshot)) as UseChat
let value: number | undefined = 42
const listeners = new Set<() => void>()
const source: ConversationLocationDataSource<number | undefined> = {
getSnapshot: () => value,
subscribe: (listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
}
const data = {
get: () => value,
source: () => source,
} as unknown as ConversationLocationDataStore<ConversationTurnDataMap>
const useChat = vi.fn(() => { throw new Error('Turn data must not read the Chat snapshot') })
const useTurnData = spec.inject.hooks.turnData(
{ useChat } as Parameters<typeof spec.inject.hooks.turnData>[0],
'node-1',
{ useChat } as unknown as Parameters<typeof spec.inject.hooks.turnData>[0],
data,
)
const data = { get: (key: string) => key === 'metric' ? 42 : undefined }
const turn = { data }
const Probe = ({ useData }: { useData: UseChatNodeTurnData }) => (
<output>{useData('metric') ?? 'missing'}</output>
)
const view = render(<Probe useData={useTurnData} />)
snapshot = chatSnapshot({
nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never,
})
expect(useTurnData('metric')).toBe(42)
snapshot = chatSnapshot({
nodes: { get: () => ({ location: { kind: 'step', turn } }), values: () => [] } as never,
})
expect(useTurnData('metric')).toBe(42)
snapshot = chatSnapshot({
nodes: { get: () => ({ location: { kind: 'session' } }), values: () => [] } as never,
})
expect(useTurnData('metric')).toBeUndefined()
snapshot = chatSnapshot({
nodes: { get: () => undefined, values: () => [] },
})
expect(useTurnData('metric')).toBeUndefined()
expect(view.getByText('42')).toBeTruthy()
expect(useChat).not.toHaveBeenCalled()
act(() => {
value = 43
for (const listener of [...listeners]) listener()
})
expect(view.getByText('43')).toBeTruthy()
view.rerender(<Probe useData={spec.inject.hooks.turnData(
{ useChat } as unknown as Parameters<typeof spec.inject.hooks.turnData>[0],
undefined,
)} />)
expect(view.getByText('missing')).toBeTruthy()
expect(useChat).not.toHaveBeenCalled()
view.unmount()
await b.runtime.dispose()
})
})
@@ -137,14 +137,22 @@ describe('chat flow font-size axis', () => {
// no-hover devices keep the row visible. 'always' has no rule at all —
// absence, not an override, keeps the latest turn's row shown.
const css = read('MessageIconActions.module.css')
expect(declarationsFrom(css, "[data-actions-reveal='hover'] .actions"))
.toEqual(expect.arrayContaining(['opacity: 0']))
expect(css).toMatch(
/\[data-actions-reveal='hover'\]:hover \.actions,\s*\[data-actions-reveal='hover'\]:focus-within \.actions \{\s*opacity: 1/,
)
expect(css).toContain("[data-actions-reveal='hover'] .actions,")
expect(css).toMatch(/\) \.actions \{\s*opacity: 0/)
expect(css).toContain("[data-actions-reveal='hover']:hover .actions,")
expect(css).toContain("[data-actions-reveal='hover']:focus-within .actions,")
expect(css).toMatch(/\):focus-within \.actions \{\s*opacity: 1/)
expect(css).not.toContain("[data-actions-reveal='always']")
})
it('uses flow sibling selectors to reveal only the latest user-authored row', () => {
const css = read('MessageIconActions.module.css')
const userKinds = ":is([data-chat-flow-kind='user'], [data-chat-flow-kind='steering'])"
expect(css).toContain(`${userKinds}:has(\n ~ ${userKinds}\n ) .actions`)
expect(css).toContain('):hover .actions')
expect(css).toContain('):focus-within .actions')
})
it('the interrupted-turn tag stays fixed like the dense token variants', () => {
// 11px would fall to an illegible 9px at the 12px floor; the tag is
// exempt from the axis the same way small/code tokens are.
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ChatConversationViewNode, ChatNodeSource,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
const timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
function userNode(index: number, text = `message ${String(index)}`): ChatConversationViewNode {
return {
key: `user:${String(index)}`,
id: String(index),
target: 'chat',
kind: 'user',
anchorSeq: index,
location: { kind: 'session' },
visibility: 'visible',
data: {
kind: 'user',
messageId: `message-${String(index)}`,
seq: index,
time: index,
content: [{ type: 'text', text }],
source: null,
},
}
}
describe('Chat Node keyed sources', () => {
it('notifies only the updated key among 4,000 mounted sources', () => {
const builder = new ChatSnapshotBuilder()
const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1))
const initial = builder.replace({ nodes, timeline })
const listeners = nodes.map(() => vi.fn())
const sources: ChatNodeSource[] = nodes.map((node, index) => {
const source = initial.nodes.source(node.key)
source.subscribe(listeners[index]!)
return source
})
const target = 2_347
const changed = userNode(target + 1, 'streamed update')
const next = builder.apply({ upserts: [changed], timeline })
expect(listeners[target]).toHaveBeenCalledOnce()
expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1)
expect(next.nodes.source(changed.key)).toBe(sources[target])
expect(next.nodes.get(changed.key)).toBe(changed)
})
})
@@ -1,10 +1,11 @@
import type {
AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore,
ChatTurnProcessPresentation, CompactionSummaryNode, FinalAssistantChatData, LegacyConversationSlice,
PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {
ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
@@ -12,9 +13,10 @@ import {
sameTurnNavigationItem, turnNavigationItem,
} from '../src/client/conversation-nodes/turn-navigation.ts'
import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { ChatTurnProcessProjector } from '../src/client/conversation-nodes/turn-process-presentation.ts'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import {
encodeTurnProcess, isSubagentDelegationTool, TURN_PROCESS_INDEPENDENT_KINDS,
isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS,
type TurnProcessSpec,
} from '../src/client/contract/turn-process.ts'
@@ -24,6 +26,19 @@ function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function cachedSource<Key, Source>(
sources: Map<Key, Source>,
key: Key,
create: () => Source,
): Source {
let source = sources.get(key)
if (source === undefined) {
source = create()
sources.set(key, source)
}
return source
}
function sameFixtureLocation(
left: ChatConversationViewNode['location'],
right: ChatConversationViewNode['location'],
@@ -52,10 +67,7 @@ function nodeSource(node: ChatConversationViewNode): unknown {
if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root
if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current
if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq
if (node.kind === 'turn-process') {
const data = node.data as TurnProcessSpec
return encodeTurnProcess(data)
}
if (node.kind === 'turn-process') return node.data
return node.data
}
@@ -63,35 +75,101 @@ function toolCallName(call: ToolCallBlock): string | null {
return 'name' in call ? call.name : call.call?.name ?? null
}
class FixtureSource<Value> {
private readonly listeners = new Set<() => void>()
private published: Value
constructor(
private readonly read: () => Value,
) {
this.published = read()
}
readonly getSnapshot = (): Value => this.read()
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
publish(): void {
const next = this.getSnapshot()
if (this.published === next) return
this.published = next
for (const listener of [...this.listeners]) listener()
}
}
class FixtureNodeStore implements ChatNodeStore {
private byKey = new Map<string, ChatConversationViewNode>()
private readonly turnProcesses = new ChatTurnProcessProjector()
private readonly sources = new Map<string, FixtureSource<ChatConversationViewNode | undefined>>()
private readonly processSources = new Map<string, FixtureSource<ChatTurnProcessPresentation | undefined>>()
private readonly dirtyKeys = new Set<string>()
private readonly dirtyProcessKeys = new Set<string>()
private list: readonly ChatConversationViewNode[] = EMPTY
get(key: string): ChatConversationViewNode | undefined {
return this.byKey.get(key)
}
source(key: string): ChatNodeSource {
return cachedSource(this.sources, key, () => new FixtureSource(() => this.get(key)))
}
processSource(key: string): ChatNodeProcessSource {
return cachedSource(this.processSources, key, () => new FixtureSource(() => this.process(key)))
}
process(key: string): ChatTurnProcessPresentation | undefined {
return this.turnProcesses.get(this.get(key) as ChatNode | undefined)
}
values(): readonly ChatConversationViewNode[] {
return this.list
}
replace(candidates: readonly ChatConversationViewNode[]): void {
const previous = this.byKey
const next = new Map<string, ChatConversationViewNode>()
const list = candidates.map((candidate) => {
const previous = this.byKey.get(candidate.key)
const node = previous !== undefined
&& previous.kind === candidate.kind
&& previous.anchorSeq === candidate.anchorSeq
&& sameFixtureLocation(previous.location, candidate.location)
&& previous.visibility === candidate.visibility
&& nodeSource(previous) === nodeSource(candidate)
? previous
const existing = previous.get(candidate.key)
const node = existing !== undefined
&& existing.kind === candidate.kind
&& existing.anchorSeq === candidate.anchorSeq
&& sameFixtureLocation(existing.location, candidate.location)
&& existing.visibility === candidate.visibility
&& nodeSource(existing) === nodeSource(candidate)
? existing
: candidate
next.set(node.key, node)
return node
})
this.byKey = next
this.list = sameValues(this.list, list) ? this.list : list
const keys = new Set([...previous.keys(), ...next.keys()])
for (const key of keys) {
if (previous.get(key) !== next.get(key)) {
this.dirtyKeys.add(key)
this.dirtyProcessKeys.add(key)
}
}
}
replaceProcesses(order: readonly string[], locations: ChatLocationNodeIndex): void {
const changed = this.turnProcesses.replace(order, locations, this)
for (const turn of changed) {
for (const key of locations.getTurn(turn)) this.dirtyProcessKeys.add(key)
}
}
publish(): void {
const dirty = [...this.dirtyKeys]
const dirtyProcesses = [...this.dirtyProcessKeys]
this.dirtyKeys.clear()
this.dirtyProcessKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
for (const key of dirtyProcesses) this.processSources.get(key)?.publish()
}
}
@@ -118,6 +196,8 @@ class FixtureLocationIndex implements ChatLocationNodeIndex {
class FixtureTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
private readonly sources = new Map<string, FixtureSource<unknown>>()
private readonly dirtyKeys = new Set<string>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
@@ -125,11 +205,26 @@ class FixtureTurnDataStore implements ConversationLocationDataStore<Conversation
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
source<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): ConversationLocationDataSource<Readonly<ConversationTurnDataMap[Key]> | undefined> {
const source = cachedSource(this.sources, key, () => new FixtureSource(() => this.values.get(key)))
return source as ConversationLocationDataSource<Readonly<ConversationTurnDataMap[Key]> | undefined>
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
value: ConversationTurnDataMap[Key],
): void {
if (this.values.get(key) === value) return
this.values.set(key, value)
this.dirtyKeys.add(key)
}
publish(): void {
const dirty = [...this.dirtyKeys]
this.dirtyKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
}
}
@@ -311,7 +406,7 @@ export function chatSnapshotFixture(input: {
const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
?? controlAnchor
const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
const spec: TurnProcessSpec = {
const candidate: TurnProcessSpec = {
turn: turnNumber,
controlAnchorSeq: controlAnchor.anchorSeq,
processStartSeq: processStart.anchorSeq,
@@ -331,7 +426,11 @@ export function chatSnapshotFixture(input: {
return name !== null && isSubagentDelegationTool(name)
}).length,
}
dataStore.set('turn-process', encodeTurnProcess(spec))
const previousSpec = dataStore.get('turn-process')
const spec = previousSpec !== undefined && sameTurnProcessSpec(previousSpec, candidate)
? previousSpec
: candidate
dataStore.set('turn-process', spec)
const turn = turns.get(turnNumber)
if (turn !== undefined) {
nodes.push({
@@ -407,6 +506,7 @@ export function chatSnapshotFixture(input: {
? previous.locations
: new FixtureLocationIndex()
locations.replace(byTurn)
store.replaceProcesses(order, locations)
const timeline = previous !== undefined
&& previous.legacy.turnTimings === legacy.turnTimings
&& previous.legacy.turnEnds === legacy.turnEnds
@@ -420,6 +520,8 @@ export function chatSnapshotFixture(input: {
&& derived.every((item, index) => sameTurnNavigationItem(kept[index], item))
? kept
: derived
for (const data of turnData.values()) data.publish()
store.publish()
return {
order,
nodes: store,
@@ -162,6 +162,25 @@ describe('StatsLine', () => {
return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
}
it('mounts one size observer only while a non-empty line exists', () => {
let observers = 0
class CountingResizeObserver {
constructor(_callback: ResizeObserverCallback) { observers++ }
observe(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', CountingResizeObserver)
const { set, source } = makeSource()
render(<StatsLine {...props(source, {})} />)
expect(observers).toBe(0)
act(() => { set({ nodes: [assistant(1, 1)] }) })
expect(observers).toBe(1)
act(() => { set({ nodes: [assistant(1, 1), assistant(2, 1)] }) })
expect(observers).toBe(1)
})
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
@@ -24,26 +24,26 @@ describe('createChatStore', () => {
expect(second.store.getSnapshot().selection).toBeNull()
})
it('stores only manually expanded Turn-process generations', () => {
it('stores only manually expanded Turn-process answers', () => {
const store = createChatStore().create()
store.actions.setTurnProcessOpen(2, '2|3', true)
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|3' }])
store.actions.setTurnProcessOpen(2, 3, true)
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, answerStep: 3 }])
store.actions.setTurnProcessOpen(2, '2|4', true)
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|4' }])
store.actions.setTurnProcessOpen(2, 4, true)
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, answerStep: 4 }])
store.actions.setTurnProcessOpen(2, '2|4', false)
store.actions.setTurnProcessOpen(2, 4, false)
expect(store.store.getSnapshot().turnProcesses).toEqual([])
})
it('closes only the requested Turn-process entry', () => {
const store = createChatStore().create()
store.actions.setTurnProcessOpen(2, '2|3', true)
store.actions.setTurnProcessOpen(3, '3|4', true)
store.actions.setTurnProcessOpen(2, 3, true)
store.actions.setTurnProcessOpen(3, 4, true)
store.actions.setTurnProcessOpen(2, '2|3', false)
store.actions.setTurnProcessOpen(9, '9|10', false)
store.actions.setTurnProcessOpen(2, 3, false)
store.actions.setTurnProcessOpen(9, 10, false)
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, generation: '3|4' }])
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, answerStep: 4 }])
})
})
@@ -13,16 +13,21 @@ import type {
import type {
SessionListState, SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ConversationLocationDataStore, ConversationTurnDataMap,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
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 { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
import { useTurnDataValue } from '../src/client/chat/use-turn-data.ts'
import { zh } from '../src/client/locale.ts'
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
@@ -35,7 +40,7 @@ import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { encodeTurnProcess } from '../src/client/contract/turn-process.ts'
import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
afterEach(() => {
@@ -200,6 +205,21 @@ function emptyWorkspaces() {
return bindSnapshotSelector(store)
}
function bindKeyedSnapshotSelector<Value>(
resolve: (key: string) => ObservableSnapshot<Value>,
): KeyedSnapshotSelectorHook<Value> {
const hooks = new WeakMap<object, SnapshotSelectorHook<Value>>()
return ((key: string, selector?: (value: Value) => unknown, equal?: (left: unknown, right: unknown) => boolean) => {
const source = resolve(key)
let useValue = hooks.get(source)
if (useValue === undefined) {
useValue = bindSnapshotSelector(source)
hooks.set(source, useValue)
}
return useValue(selector ?? ((value: Value) => value), equal)
}) as KeyedSnapshotSelectorHook<Value>
}
function makeHarness(
init: HarnessUpdate = {},
sessionOverrides: Partial<SessionSnapshot> = {},
@@ -219,6 +239,12 @@ function makeHarness(
}
const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
const useChatNode = bindKeyedSnapshotSelector(
key => chatSource.source.getSnapshot().nodes.source(key),
)
const useChatNodeProcess = bindKeyedSnapshotSelector(
key => chatSource.source.getSnapshot().nodes.processSource(key),
)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
const loadOlder = vi.fn()
@@ -259,13 +285,9 @@ function makeHarness(
if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never)
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
const nodeOwner = owner as RoutedChatNodeOwner
const nodeKey = opts?.hookContext as string | undefined
const useTurnData: UseChatNodeTurnData = dataKey => props.useChat((snapshot) => {
const location = nodeKey === undefined ? undefined : snapshot.nodes.get(nodeKey)?.location
return location?.kind === 'turn' || location?.kind === 'step'
? location.turn.data.get(dataKey)
: undefined
})
const turnData = opts?.hookContext as
ConversationLocationDataStore<ConversationTurnDataMap> | undefined
const useTurnData: UseChatNodeTurnData = dataKey => useTurnDataValue(turnData, dataKey)
const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
{ ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
)
@@ -344,6 +366,8 @@ function makeHarness(
sessionId: SID,
useSession: bindSnapshotSelector(session.source),
useChat: bindSnapshotSelector(chatSource.source),
useChatNode,
useChatNodeProcess,
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
@@ -415,13 +439,18 @@ function makeHarness(
function readerScroll(element: HTMLElement, top: number): void {
element.scrollTop = top
fireEvent.scroll(element)
fireEvent(element, new Event('scrollend'))
}
function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
}
function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot {
function withSystemPrompt(
snapshot: ChatSnapshot,
builder = new ChatSnapshotBuilder(),
text = '# System',
): ChatSnapshot {
const turn = snapshot.timeline.turns.get(1)
if (turn === undefined) throw new Error('fixture lacks Turn 1')
const prompt: ChatNode<'system-prompt'> = {
@@ -434,7 +463,7 @@ function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapsh
visibility: 'visible',
data: { text },
}
return new ChatSnapshotBuilder().replace({
return builder.replace({
nodes: [prompt, ...snapshot.nodes.values()],
timeline: snapshot.timeline,
})
@@ -1315,25 +1344,10 @@ describe('ChatView', () => {
})
it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
const builder = new ChatSnapshotBuilder()
const initial = withSystemPrompt(chatSnapshotFixture({
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
}))
const running = withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
],
}))
const completed = withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
assistant(6, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 7]]),
}))
}), builder)
const h = makeHarness({ chat: initial }, { running: true })
const view = render(<h.ChatView {...h.props} />)
const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
@@ -1342,14 +1356,38 @@ describe('ChatView', () => {
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
act(() => { h.set({ chat: running, running: true }) })
act(() => {
h.set({
chat: withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
],
}), builder),
running: true,
})
})
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
expect(promptRow.getAttribute('hidden')).toBeNull()
act(() => { h.set({ chat: completed, running: false }) })
act(() => {
h.set({
chat: withSystemPrompt(chatSnapshotFixture({
nodes: [
userInTurn(2, 'question', 1),
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
assistant(6, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 7]]),
}), builder),
running: false,
})
})
const toggle = turnProcessControl(view.container)!
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(renderedFlowKinds(view.container)).toEqual([
@@ -1635,10 +1673,11 @@ describe('ChatView', () => {
|| (process.location.kind !== 'turn' && process.location.kind !== 'step')
|| process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process')
const turnData = process.location.turn.data as typeof process.location.turn.data & {
set(key: 'turn-process', value: ReturnType<typeof encodeTurnProcess>): void
set(key: 'turn-process', value: TurnProcessSpec): void
publish(): void
}
const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
turnData.set('turn-process', encodeTurnProcess(partialSpec))
turnData.set('turn-process', partialSpec)
const partialProcess = { ...process, data: partialSpec }
const builder = new ChatSnapshotBuilder()
const partial = builder.replace({
@@ -1651,7 +1690,7 @@ describe('ChatView', () => {
const beforeKeys = partial.locations.getTurn(1)
const completeSpec = { ...partialSpec, processStartSeq: 2 }
turnData.set('turn-process', encodeTurnProcess(completeSpec))
turnData.set('turn-process', completeSpec)
const complete = builder.apply({
upserts: [{ ...partialProcess, data: completeSpec }],
timeline: source.timeline,
@@ -1663,7 +1702,10 @@ describe('ChatView', () => {
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
act(() => { h.set({ chat: complete, hasMore: false }) })
act(() => {
turnData.publish()
h.set({ chat: complete, hasMore: false })
})
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
})
@@ -1814,7 +1856,7 @@ describe('ChatView', () => {
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
it('user rows and turn tails both gate the whole actions row by recency', () => {
it('keeps only the latest turn tail actions permanently visible', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
@@ -1829,15 +1871,11 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 3], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
// The last user-authored row and the latest turn's tail stay shown;
// every earlier row of either kind reveals on hover.
const tails = view.container.querySelectorAll('[data-turn-tail]')
expect(new Map([...tails].map(tail => [
tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
const userRows = [...view.container.querySelectorAll('[data-actions-reveal]')]
.filter(row => row.getAttribute('data-turn-tail') === null)
expect(userRows.map(row => row.getAttribute('data-actions-reveal'))).toEqual(['hover', 'always'])
expect(view.container.querySelectorAll('[data-chat-flow-kind="user"]')).toHaveLength(2)
})
it('the run-time label is withheld when the turn start is outside the window', () => {
@@ -2278,6 +2316,7 @@ describe('ChatView', () => {
// lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700)
fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
@@ -2300,6 +2339,7 @@ describe('ChatView', () => {
// baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500
fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
expect(view.getByLabelText('回到底部')).toBeTruthy()
})
@@ -2322,6 +2362,7 @@ describe('ChatView', () => {
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 700
fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(1_200)
@@ -16,7 +16,6 @@ import {
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import { decodeTurnProcess } from '../src/client/contract/turn-process.ts'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
@@ -120,6 +119,37 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und
return value.nodes.values().find(candidate => candidate.kind === kind)
}
function comparableSnapshot(value: ChatSnapshot) {
const nodes = value.nodes.values()
return {
order: value.order,
nodes: nodes.map(candidate => ({
...candidate,
location: candidate.location.kind === 'step'
? {
kind: 'step',
turn: candidate.location.turn.turn,
turnStatus: candidate.location.turn.status,
step: candidate.location.step.step,
stepStatus: candidate.location.step.status,
}
: candidate.location.kind === 'turn'
? {
kind: 'turn',
turn: candidate.location.turn.turn,
turnStatus: candidate.location.turn.status,
}
: { kind: candidate.location.kind },
})),
processes: nodes.map(candidate => [
candidate.key,
value.nodes.processSource(candidate.key).getSnapshot(),
]),
navigation: value.navigation.items(),
legacy: value.legacy,
}
}
function textMessage(id: string, text: string) {
return {
id,
@@ -257,10 +287,7 @@ describe('built-in conversation node Definitions', () => {
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
}),
])
const process = () => {
const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
return signature === undefined ? undefined : decodeTurnProcess(signature)
}
const process = () => snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null })
expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null })
@@ -323,8 +350,8 @@ describe('built-in conversation node Definitions', () => {
}),
at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
])
const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature))
const recoveredProcess = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
expect(recoveredProcess)
.toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
const partialWindow = assembler([
@@ -333,8 +360,8 @@ describe('built-in conversation node Definitions', () => {
}),
at(31, 'step/end', { turn: 3, step: 4 }),
], true)
const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature))
const partialProcess = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
expect(partialProcess)
.toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 })
})
@@ -365,8 +392,8 @@ describe('built-in conversation node Definitions', () => {
at(11, 'step/end', { turn: 1, step: 2 }),
at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({
const process = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
expect(process).toMatchObject({
messageCount: 1,
toolCallCount: 1,
subagentCount: 1,
@@ -592,10 +619,15 @@ describe('built-in conversation node Definitions', () => {
}),
])
const read = () => {
const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
if (signature === undefined) throw new Error('turn-process signature is unavailable')
return decodeTurnProcess(signature)
const process = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
if (process === undefined) throw new Error('turn-process data is unavailable')
return process
}
const streamingNode = node(snapshot(value), 'assistant-step')
if (streamingNode === undefined) throw new Error('streaming Assistant node is unavailable')
const processSource = snapshot(value).nodes.processSource(streamingNode.key)
let processNotifications = 0
processSource.subscribe(() => { processNotifications++ })
const streaming = read()
value.append(at(44, 'assistant/message', {
turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'),
@@ -606,6 +638,77 @@ describe('built-in conversation node Definitions', () => {
expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null })
expect(settled.answerAnchorSeq).toBe(44)
expect(settled.answerStep).toBe(1)
expect(processNotifications).toBe(1)
expect(processSource.getSnapshot()?.spec.answerAnchorSeq).toBe(44)
})
it('reuses the open Turn-process projection across continuing Assistant chunks', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'first' },
}),
])
const before = snapshot(value)
const processNode = node(before, 'turn-process')
const processData = before.timeline.turns.get(1)?.data.get('turn-process')
const assistantNode = node(before, 'assistant-step')
if (assistantNode === undefined) throw new Error('Assistant node is unavailable')
const processSource = before.nodes.processSource(assistantNode.key)
const processPresentation = processSource.getSnapshot()
let processNotifications = 0
processSource.subscribe(() => { processNotifications++ })
value.append(at(4, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: ' second' },
}))
value.flush()
const after = snapshot(value)
expect(after.timeline.turns.get(1)?.data.get('turn-process')).toBe(processData)
expect(node(after, 'turn-process')).toBe(processNode)
expect(node(after, 'assistant-step')).not.toBe(assistantNode)
expect(processSource.getSnapshot()).toBe(processPresentation)
expect(processNotifications).toBe(0)
})
it('notifies process sources only for Nodes in the changed Turn', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/message', {
turn: 1, step: 1, message: assistantMessage('answer-1', 'first answer'),
}, { surfaceOp: 'append' }),
at(4, 'step/end', { turn: 1, step: 1 }),
at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
at(6, 'turn/start', { turn: 2 }),
at(7, 'step/start', { turn: 2, step: 1 }),
at(8, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}),
])
const assistants = snapshot(value).nodes.values()
.filter((candidate): candidate is ChatConversationViewNode & { data: AssistantChatData } => (
candidate.kind === 'assistant-step'
))
const first = assistants.find(candidate => candidate.data.turn === 1)
const second = assistants.find(candidate => candidate.data.turn === 2)
if (first === undefined || second === undefined) throw new Error('Assistant fixtures are unavailable')
let firstNotifications = 0
let secondNotifications = 0
snapshot(value).nodes.processSource(first.key).subscribe(() => { firstNotifications++ })
snapshot(value).nodes.processSource(second.key).subscribe(() => { secondNotifications++ })
value.append(at(9, 'assistant/message', {
turn: 2, step: 1, message: assistantMessage('answer-2', 'second answer'),
}, { surfaceOp: 'append' }))
value.append(at(10, 'step/end', { turn: 2, step: 1 }))
value.append(at(11, 'turn/end', { turn: 2, reason: { kind: 'completed' } }))
value.flush()
expect(firstNotifications).toBe(0)
expect(secondNotifications).toBe(1)
})
it('anchors a streamed non-text answer from its block start', () => {
@@ -619,11 +722,11 @@ describe('built-in conversation node Definitions', () => {
const current = snapshot(value)
const process = node(current, 'turn-process')
const answer = node(current, 'assistant-step')
const signature = current.timeline.turns.get(5)?.data.get('turn-process')
const processData = current.timeline.turns.get(5)?.data.get('turn-process')
expect(process?.anchorSeq).toBe(51.9)
expect(answer?.anchorSeq).toBe(52)
expect(signature === undefined ? undefined : decodeTurnProcess(signature))
expect(processData)
.toMatchObject({ answerAnchorSeq: null, answerStep: null })
})
@@ -640,6 +743,9 @@ describe('built-in conversation node Definitions', () => {
const runningSnapshot = snapshot(value)
const running = node(runningSnapshot, 'assistant-step')
expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
expect(running?.location.kind === 'step'
? running.location.step.data.get('assistant-step')
: undefined).toBe(running?.data)
const order = runningSnapshot.order
value.append(at(4, 'assistant/message', {
@@ -654,6 +760,9 @@ describe('built-in conversation node Definitions', () => {
expect(settled?.key).toBe(running?.key)
expect(settledSnapshot.order).toBe(order)
expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
expect(settled?.location.kind === 'step'
? settled.location.step.data.get('assistant-step')
: undefined).toBe(settled?.data)
const interruptedValue = assembler([
at(10, 'turn/start', { turn: 2 }),
@@ -826,7 +935,7 @@ describe('built-in conversation node Definitions', () => {
expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
const packed = assembler(packedHistory)
expect(snapshot(packed)).toEqual(snapshot(scalar))
expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
const running = node(snapshot(packed), 'assistant-step')
expect(running).toMatchObject({ anchorSeq: 6 })
expect(running?.data).toMatchObject({
@@ -843,7 +952,7 @@ describe('built-in conversation node Definitions', () => {
value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
value.flush()
}
expect(snapshot(packed)).toEqual(snapshot(scalar))
expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
const partialHistory = [
@@ -853,7 +962,7 @@ describe('built-in conversation node Definitions', () => {
]
const partialScalar = snapshot(assembler(partialHistory, true))
const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
expect(partialPacked).toEqual(partialScalar)
expect(comparableSnapshot(partialPacked)).toEqual(comparableSnapshot(partialScalar))
expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
@@ -889,7 +998,7 @@ describe('built-in conversation node Definitions', () => {
]
const finalizedScalar = snapshot(assembler(finalizedHistory))
const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
expect(finalizedPacked).toEqual(finalizedScalar)
expect(comparableSnapshot(finalizedPacked)).toEqual(comparableSnapshot(finalizedScalar))
const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
@@ -911,7 +1020,7 @@ describe('built-in conversation node Definitions', () => {
]
const namedToolScalar = snapshot(assembler(namedToolHistory))
const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
expect(namedToolPacked).toEqual(namedToolScalar)
expect(comparableSnapshot(namedToolPacked)).toEqual(comparableSnapshot(namedToolScalar))
const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
})
@@ -1,46 +1,20 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locale.ts'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
let nextAnimationFrameId = 1
let animationFrames = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(count: number): void {
for (let index = 0; index < count; index += 1) {
const callbacks = [...animationFrames.values()]
animationFrames.clear()
for (const callback of callbacks) callback(index)
}
}
beforeEach(() => {
nextAnimationFrameId = 1
animationFrames = new Map()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId
nextAnimationFrameId += 1
animationFrames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
animationFrames.delete(id)
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
const t = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
describe('ReasoningRow', () => {
it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
it('follows the latest streaming line, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
t={t}
@@ -50,11 +24,8 @@ describe('ReasoningRow', () => {
/>,
)
expect(view.getByText('运行中')).toBeTruthy()
const summary = view.getByText('Newest reasoning tokens')
Object.defineProperties(summary, {
scrollWidth: { configurable: true, value: 300 },
clientWidth: { configurable: true, value: 100 },
})
expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
.toBe('true')
view.rerender(
<AssistantMarkdown
@@ -64,12 +35,8 @@ describe('ReasoningRow', () => {
renderMessageImages={renderMessageImages}
/>,
)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(2)
expect(summary.scrollLeft).toBe(0)
flushAnimationFrames(1)
expect(summary.scrollLeft).toBe(200)
expect(summary.getAttribute('data-follow-end')).toBe('true')
expect(view.getByText('Newest reasoning tokens keep arriving').parentElement
?.getAttribute('data-follow-end')).toBe('true')
view.rerender(
<AssistantMarkdown
@@ -79,11 +46,9 @@ describe('ReasoningRow', () => {
renderMessageImages={renderMessageImages}
/>,
)
flushAnimationFrames(3)
expect(view.getByText('Inspect the session')).toBeTruthy()
const settledSummary = view.getByText('Inspect the session')
expect(view.queryByText('运行中')).toBeNull()
expect(summary.scrollLeft).toBe(0)
expect(summary.hasAttribute('data-follow-end')).toBe(false)
expect(settledSummary.parentElement?.hasAttribute('data-follow-end')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
@@ -202,11 +202,7 @@ export function apply(ctx: Context): void {
'conversation.session.header': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
@@ -277,8 +273,12 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
},
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {
@@ -1,10 +1,6 @@
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
* installed business packages supply their concrete keys in consuming Client programs. */
/** Definition-local identity and lifecycle role extracted from one event. */
export interface ConversationMatchResult {
readonly id: string
@@ -17,6 +13,14 @@ export interface ConversationTurnDataMap {}
/** Merge-extensible business values published against one Step. */
export interface ConversationStepDataMap {}
/** Observable value for one independently owned Location-data key. */
export interface ConversationLocationDataSource<Value> {
/** @returns the current value. */
readonly getSnapshot: () => Value
/** @param listener - callback for value changes. @returns the unsubscribe function. */
readonly subscribe: (listener: () => void) => () => void
}
/** Stable keyed reader for independently owned Location business values. */
export interface ConversationLocationDataStore<DataMap extends object> {
/**
@@ -25,6 +29,14 @@ export interface ConversationLocationDataStore<DataMap extends object> {
* @returns latest immutable value, when its owning Context has published one.
*/
get<Key extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | undefined
/**
* Observe one business value without subscribing to unrelated Location keys.
* @param key - declaration-merged business key.
* @returns identity-stable source for the current value.
*/
source<Key extends keyof DataMap & string>(
key: Key,
): ConversationLocationDataSource<Readonly<DataMap[Key]> | undefined>
}
interface ConversationLocationDataValue {
@@ -35,30 +47,35 @@ interface ConversationLocationDataValue {
readonly value: unknown
}
type RegisteredTurnData = {
[Key in keyof ConversationTurnDataMap & string]: {
type RegisteredTurnData<DataMap extends object> = {
[Key in Extract<keyof DataMap, string>]: {
readonly kind: 'turn'
readonly turn: number
readonly key: Key
readonly value: ConversationTurnDataMap[Key]
readonly value: DataMap[Key]
}
}[keyof ConversationTurnDataMap & string]
}[Extract<keyof DataMap, string>]
type RegisteredStepData = {
[Key in keyof ConversationStepDataMap & string]: {
type RegisteredStepData<DataMap extends object> = {
[Key in Extract<keyof DataMap, string>]: {
readonly kind: 'step'
readonly turn: number
readonly step: number
readonly key: Key
readonly value: ConversationStepDataMap[Key]
readonly value: DataMap[Key]
}
}[keyof ConversationStepDataMap & string]
}[Extract<keyof DataMap, string>]
type ConversationLocationDataOf<TurnData extends object, StepData extends object> =
[keyof TurnData | keyof StepData] extends [never]
? ConversationLocationDataValue
: RegisteredTurnData<TurnData> | RegisteredStepData<StepData>
/** One Definition-owned value attached to an Engine-owned Turn or Step. */
export type ConversationLocationData =
[keyof ConversationTurnDataMap | keyof ConversationStepDataMap] extends [never]
? ConversationLocationDataValue
: RegisteredTurnData | RegisteredStepData
export type ConversationLocationData = ConversationLocationDataOf<
ConversationTurnDataMap,
ConversationStepDataMap
>
/** Immutable resolved boundary for one Agent step. */
export interface StepLocation {
@@ -158,7 +175,7 @@ export interface ConversationContextReader {
previous<State>(kind: string): ConversationPreviousContext<State> | undefined
}
/** Requested cadence for materializing updated business State into view Nodes. */
/** Requested cadence; `animation-frame` materializes after three browser animation frames. */
export type ConversationPublication = 'none' | 'animation-frame' | 'immediate'
/** Engine-owned Location data publication phase. */
@@ -210,11 +227,14 @@ export interface ConversationNodeDefinition<State = unknown> {
* the same Location key.
* @param context - latest complete Context.
* @param scope - Location hierarchy level currently being materialized.
* @returns current Location value, or null while unavailable.
* @param previous - value from the preceding materialization; return it when unchanged.
* @returns current Location value, preserving value identity while unchanged,
* or null while unavailable.
*/
buildLocationData?(
context: ConversationNodeContext<State>,
scope: ConversationLocationDataScope,
previous: ConversationLocationData | null,
): ConversationLocationData | null
/**
* Materialize one final Node for this Definition's declared view target.
@@ -128,11 +128,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Floating entries rendered inside the resident composer card. */
'conversation.input.overlay': { kind: 'list'; scope: 'session' }
/** Ambient entries below the composer card. */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.composer.dock': { kind: 'list'; scope: 'session' }
/** Compact controls at the left of the composer tool row. */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.input.left': { kind: 'list'; scope: 'session' }
/** Compact controls before the composer submit action. */
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
'conversation.input.right': { kind: 'list'; scope: 'session' }
/** Resident composer body, including the no-Session inert state. */
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/** Optional draft-image rail and drop target. */
@@ -255,14 +255,6 @@ export interface ComposerBarOwnerProps {
placeholder?: string
/** Optional content rendered above the composer surface. */
accessory?: ReactNode
/** Floating overlay content rendered inside the composer card. */
overlay?: ReactNode
/** Left-side input controls. */
leftItems?: ReactNode
/** Right-side input controls. */
rightItems?: ReactNode
/** Ambient content below the card. */
footer?: ReactNode
}
/** Package-private operations injected into the resident composer bar. */
@@ -296,7 +288,10 @@ export interface InputControlOwnerProps {
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<
'conversation.input.attachments' | 'conversation.input.plan' | 'conversation.input.model'
| 'conversation.input.attachments' | 'conversation.input.overlay'
| 'conversation.input.left' | 'conversation.input.plan'
| 'conversation.input.right' | 'conversation.input.model'
| 'conversation.composer.dock'
>
& InjectFace<ComposerBarInjected>
& PropsLocale<'conversation'>
@@ -325,9 +320,7 @@ export type ConversationSlotProps =
& PropsRenderSlots<
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.input.dock'
| 'conversation.hero.brand.mark'
| 'conversation.hero.workspace'
| 'conversation.hero.agentPreset'
@@ -303,6 +303,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
})
published = true
}
this.locationIndex.publishData()
this.replacePending = false
this.dirty.clear()
this.dirtyByTarget.clear()
@@ -326,6 +327,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
})
published = true
}
this.locationIndex.publishData()
this.dirty.clear()
this.dirtyByTarget.clear()
this.timelineDirty = false
@@ -826,9 +828,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private buildLocationData(
context: InternalContext,
scope: ConversationLocationDataScope,
previous: ConversationLocationData | null,
): ConversationLocationData | null {
if (context.definition.buildLocationData === undefined) return null
const data = context.definition.buildLocationData(contextSnapshot(context), scope)
const data = context.definition.buildLocationData(contextSnapshot(context), scope, previous)
if (data === null) return null
if (data.kind !== scope) {
throw new Error(
@@ -853,7 +856,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
const entries: { owner: string; data: ConversationLocationData }[] = []
for (const scope of LOCATION_DATA_SCOPES) {
for (const context of this.contexts.values()) {
const data = this.buildLocationData(context, scope)
const data = this.buildLocationData(context, scope, context.locationData[scope])
context.locationData[scope] = data
if (data !== null) entries.push({ owner: context.key, data })
}
@@ -869,9 +872,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
const changes: ConversationLocationDataChange[] = []
for (const context of this.dirty) {
const previous = context.locationData[scope]
const next = this.buildLocationData(context, scope)
const next = this.buildLocationData(context, scope, previous)
if (previous === next) continue
context.locationData[scope] = next
if (previous !== next) changes.push({ owner: context.key, previous, next })
changes.push({ owner: context.key, previous, next })
}
changed = this.locationIndex.applyData(changes) || changed
}
@@ -86,10 +86,7 @@ class BoundConversation implements ConversationBinding {
rebuild(): void { this.publish(this.assembler.rebuildRegistry()) }
dispose(): void {
if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.frame)
}
this.frame = undefined
this.cancelFrame()
this.disposeFeed()
}
@@ -124,15 +121,28 @@ class BoundConversation implements ConversationBinding {
if (publication === 'none') return
if (publication === 'animation-frame' && typeof requestAnimationFrame === 'function') {
if (this.frame !== undefined) return
// Cross three paint opportunities before publishing high-frequency stream updates.
this.frame = requestAnimationFrame(() => {
this.frame = undefined
this.flush()
this.frame = requestAnimationFrame(() => {
this.frame = requestAnimationFrame(() => {
this.frame = undefined
this.flush()
})
})
})
return
}
this.cancelFrame()
this.flush()
}
private cancelFrame(): void {
if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.frame)
}
this.frame = undefined
}
private flush(): void {
if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot())
}
@@ -1,11 +1,12 @@
import {
type SessionEventLike, type SessionEventLikeEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationLocation, ConversationLocationData,
ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot,
ConversationTurnDataMap, StepLocation, TurnLocation,
ConversationLocationDataSource, ConversationLocationDataStore, ConversationStepDataMap,
ConversationTimelineSnapshot, ConversationTurnDataMap, StepLocation, TurnLocation,
} from '../contract/conversation.ts'
interface OwnedLocationData {
@@ -20,17 +21,57 @@ export interface ConversationLocationDataChange {
readonly next: ConversationLocationData | null
}
class MutableLocationDataSource implements ConversationLocationDataSource<unknown> {
private readonly listeners = new Set<() => void>()
private published: unknown
constructor(
private readonly store: MutableLocationDataStore,
private readonly key: string,
) {
this.published = store.get(key)
}
readonly getSnapshot = (): unknown => this.store.get(this.key)
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
publish(): void {
const next = this.getSnapshot()
if (this.published === next) return
this.published = next
notifySubscribers(this.listeners, `[ui-conversation] Location data ${this.key}`)
}
}
class MutableLocationDataStore {
private entries = new Map<string, OwnedLocationData>()
private readonly sources = new Map<string, MutableLocationDataSource>()
private readonly dirtyKeys = new Set<string>()
constructor(private readonly markDirty: (store: MutableLocationDataStore) => void) {}
get(key: string): unknown {
return this.entries.get(key)?.value
}
source(key: string): ConversationLocationDataSource<unknown> {
let source = this.sources.get(key)
if (source === undefined) {
source = new MutableLocationDataSource(this, key)
this.sources.set(key, source)
}
return source
}
remove(owner: string, key: string): boolean {
const current = this.entries.get(key)
if (current?.owner !== owner) return false
this.entries.delete(key)
this.changed(key)
return true
}
@@ -41,22 +82,32 @@ class MutableLocationDataStore {
}
if (current?.value === value) return false
this.entries.set(key, { owner, value })
this.changed(key)
return true
}
replace(entries: ReadonlyMap<string, OwnedLocationData>): boolean {
let changed = this.entries.size !== entries.size
if (!changed) {
for (const [key, value] of entries) {
const current = this.entries.get(key)
if (current?.owner !== value.owner || current.value !== value.value) {
changed = true
break
}
}
const changedKeys: string[] = []
for (const key of new Set([...this.entries.keys(), ...entries.keys()])) {
const current = this.entries.get(key)
const next = entries.get(key)
if (current?.owner !== next?.owner || current?.value !== next?.value) changedKeys.push(key)
}
if (changed) this.entries = new Map(entries)
return changed
if (changedKeys.length === 0) return false
this.entries = new Map(entries)
for (const key of changedKeys) this.changed(key)
return true
}
publish(): void {
const dirty = [...this.dirtyKeys]
this.dirtyKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
}
private changed(key: string): void {
this.dirtyKeys.add(key)
this.markDirty(this)
}
}
@@ -131,6 +182,7 @@ export class ConversationLocationIndex {
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
private readonly dirtyDataStores = new Set<MutableLocationDataStore>()
private currentTurn: number | undefined
private currentStep: number | undefined
@@ -192,6 +244,13 @@ export class ConversationLocationIndex {
return changed
}
/** Publish committed Location-data changes to their keyed sources. */
publishData(): void {
const dirty = [...this.dirtyDataStores]
this.dirtyDataStores.clear()
for (const store of dirty) store.publish()
}
/**
* Resolve the latest Location for one event.
* @param event - event already ingested into this index.
@@ -481,17 +540,21 @@ export class ConversationLocationIndex {
}
private mutableTurnData(turn: number): MutableLocationDataStore {
const current = this.turnDataStores.get(turn) ?? new MutableLocationDataStore()
const current = this.turnDataStores.get(turn) ?? this.createDataStore()
this.turnDataStores.set(turn, current)
return current
}
private mutableStepData(key: string): MutableLocationDataStore {
const current = this.stepDataStores.get(key) ?? new MutableLocationDataStore()
const current = this.stepDataStores.get(key) ?? this.createDataStore()
this.stepDataStores.set(key, current)
return current
}
private createDataStore(): MutableLocationDataStore {
return new MutableLocationDataStore(store => this.dirtyDataStores.add(store))
}
private storeFor(data: ConversationLocationData): MutableLocationDataStore {
return data.kind === 'turn'
? this.mutableTurnData(data.turn)
@@ -6,7 +6,8 @@ export { ConversationController, UnsupportedImageMediaTypeError } from './servic
export type { IConversation } from './service.ts'
export type {
ConversationContextReader, ConversationLocation,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataSource,
ConversationLocationDataStore,
ConversationMatch, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication,
ConversationStartMatch,
@@ -341,11 +341,6 @@ export function ConversationRoot({
// user clears it.
? { blocked: composerBlock, placeholder: composerBlock.reason }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: sessionId === undefined ? undefined : renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Ambient dock under the card shares the composer's width constraint.
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (
@@ -2,8 +2,8 @@
* Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* useNotices/useLexicon; layout-phase inputs (variant and placeholder) ride
* the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession.
*
* The text surface is the shell-owned Lexical editor bound here through
@@ -13,7 +13,7 @@
* trigger instead of a parallel tree.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
@@ -39,13 +39,13 @@ import css from './InputBar.module.css'
export type InputBarProps = ComposerBarProps
export function InputBar({
export const InputBar = memo(function InputBar({
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, blocked,
workspacePickerOpen = false, onRequestWorkspace,
placeholder, accessory, overlay, leftItems, rightItems, footer,
placeholder, accessory,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@@ -391,7 +391,9 @@ export function InputBar({
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{sessionId !== undefined && (
<div className={css.overlayAnchor}>{renderSlot('conversation.input.overlay', {})}</div>
)}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{renderSlot('conversation.input.attachments', {
attachments,
@@ -454,10 +456,14 @@ export function InputBar({
{accessSelect}
{sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })}
</div>
{leftItems}
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.left', {})}
</div>
<div className={css.trailing}>
{rightItems}
{input === undefined || sessionId === undefined
? null
: renderSlot('conversation.input.right', {})}
{sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{interruptible && (
@@ -499,7 +505,9 @@ export function InputBar({
</div>
</div>
</div>
{footer}
{variant === 'composer' && input !== undefined && sessionId !== undefined
? renderSlot('conversation.composer.dock', {})
: null}
</div>
)
}
})
@@ -6,7 +6,10 @@ import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/type
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { ConversationNodeAssembler as RuntimeConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import {
ConversationLocationIndex,
ConversationNodeAssembler as RuntimeConversationNodeAssembler,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
@@ -162,6 +165,54 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition<str
}
describe('ConversationNodeAssembler', () => {
it('publishes Location data through stable per-key sources', () => {
const index = new ConversationLocationIndex()
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })
index.rebuild([input(turnStart), input(stepStart)])
const location = index.locationOf(stepStart)
if (location.kind !== 'step') throw new Error('scope probe requires a Step Location')
const source = location.step.data.source('scope-probe')
const listener = vi.fn()
source.subscribe(listener)
const initial = { value: 1 }
expect(location.step.data.source('scope-probe')).toBe(source)
expect(source.getSnapshot()).toBeUndefined()
expect(index.replaceData([{
owner: 'scope-probe:1:1',
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
}])).toBe(true)
expect(source.getSnapshot()).toBe(initial)
expect(listener).not.toHaveBeenCalled()
index.publishData()
expect(listener).toHaveBeenCalledOnce()
listener.mockClear()
index.replaceData([])
index.replaceData([{
owner: 'scope-probe:1:1',
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
}])
index.publishData()
expect(listener).not.toHaveBeenCalled()
const changed = { value: 2 }
index.replaceData([{
owner: 'scope-probe:1:1',
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: changed },
}])
index.publishData()
expect(source.getSnapshot()).toBe(changed)
expect(listener).toHaveBeenCalledOnce()
index.replaceData([])
index.publishData()
expect(source.getSnapshot()).toBeUndefined()
expect(listener).toHaveBeenCalledTimes(2)
})
it('reports a replacement only when an active target has a registered builder', () => {
const assembler = new RuntimeConversationNodeAssembler(
new TestEventDefinitions([]),
@@ -1005,6 +1056,52 @@ describe('ConversationNodeAssembler', () => {
.toEqual({ step: 2, turn: 2 })
})
it('does not republish an equivalent Location value', () => {
const apply = vi.fn()
const initialValue = { value: 1 }
const changedValue = { value: 2 }
const definition: ConversationNodeDefinition<{ readonly value: ScopeProbeStepData }> = {
kind: 'scope-probe',
match: (event) => {
if (event.type === 'step/start') return { id: '1:1', role: 'start' }
if ((event.type as string) === 'scope-probe/update') return { id: '1:1', role: 'update' }
return null
},
start: () => ({ value: initialValue }),
update: (_context, match) => ({
value: (match.event.data as unknown as { changed: boolean }).changed
? changedValue
: initialValue,
}),
buildLocationData: (context, scope, previous) => {
if (scope !== 'step' || context.state === undefined) return null
if (previous?.kind === 'step'
&& previous.turn === 1
&& previous.step === 1
&& previous.key === 'scope-probe'
&& previous.value === context.state.value) return previous
return { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: context.state.value }
},
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
assembler.replaceWindow([
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
], false)
assembler.flush()
assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, changed: false })))
expect(assembler.flush()).toBe(false)
expect(apply).not.toHaveBeenCalled()
assembler.append(input(at(SessionSeq(4), 'scope-probe/update', { turn: 1, step: 1, changed: true })))
expect(assembler.flush()).toBe(true)
expect(apply).toHaveBeenCalledOnce()
})
it('updates existing turn Locations when their Step membership changes', () => {
const apply = vi.fn()
const definition: ConversationNodeDefinition<null> = {
@@ -1,6 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
createScope, MutableSessionEventSource,
@@ -17,6 +18,8 @@ import type {
const SESSION_ID = 'resident' as SessionId
afterEach(() => { vi.unstubAllGlobals() })
function sessionSnapshot(): SessionSnapshot {
return {
sessionId: SESSION_ID,
@@ -136,6 +139,106 @@ async function bootRegistries(): Promise<{
}
describe('Conversation registries', () => {
it('publishes frame-paced updates after three animation frames and lets immediate updates preempt them', async () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const requestFrame = vi.fn((callback: FrameRequestCallback) => {
nextFrame++
frames.set(nextFrame, callback)
return nextFrame
})
const cancelFrame = vi.fn((frame: number) => { frames.delete(frame) })
vi.stubGlobal('requestAnimationFrame', requestFrame)
vi.stubGlobal('cancelAnimationFrame', cancelFrame)
const { uiConversation, binding, events, views } = await bootRegistries()
const definition: ConversationNodeDefinition<number> = {
kind: 'frame-probe',
target: 'chat',
match: event => event.type === 'turn/start'
? { id: String(event.data.turn), role: 'start' }
: event.type === 'assistant/chunk' || event.type === 'assistant/message'
? { id: String(event.data.turn), role: 'update' }
: null,
start: () => 0,
update: context => context.state + 1,
publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
buildViewNode: context => ({
key: context.key,
kind: 'frame-probe',
id: context.id,
target: 'chat',
data: context.state,
}),
}
events.register(definition)
views.register(viewDefinition('chat'))
await Promise.resolve()
const conversation = uiConversation.binding(binding)
conversation.activate('chat')
const listener = vi.fn()
const unsubscribe = conversation.snapshot.subscribe(listener)
const source = binding.eventSource as MutableSessionEventSource
const append = (event: SessionEvent): void => {
source.append({ type: 'event', event })
}
append({ seq: SessionSeq(1), time: 1, type: 'turn/start', data: { turn: 1 } })
listener.mockClear()
append({
seq: SessionSeq(2),
time: 2,
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } },
})
append({
seq: SessionSeq(3),
time: 3,
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } },
})
expect(requestFrame).toHaveBeenCalledOnce()
expect(listener).not.toHaveBeenCalled()
const first = frames.get(1)
if (first === undefined) throw new Error('first animation frame was not scheduled')
frames.delete(1)
first(0)
expect(requestFrame).toHaveBeenCalledTimes(2)
expect(listener).not.toHaveBeenCalled()
const second = frames.get(2)
if (second === undefined) throw new Error('second animation frame was not scheduled')
frames.delete(2)
second(16)
expect(requestFrame).toHaveBeenCalledTimes(3)
expect(listener).not.toHaveBeenCalled()
const third = frames.get(3)
if (third === undefined) throw new Error('third animation frame was not scheduled')
frames.delete(3)
third(32)
expect(listener).toHaveBeenCalledOnce()
append({
seq: SessionSeq(4),
time: 4,
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' } },
})
append({
seq: SessionSeq(5),
time: 5,
type: 'assistant/message',
data: { turn: 1, step: 1, message: { id: 'a1', role: 'assistant', content: [] } },
} as SessionEvent)
expect(cancelFrame).toHaveBeenCalledWith(4)
expect(frames).toHaveLength(0)
expect(listener).toHaveBeenCalledTimes(2)
unsubscribe()
await binding.ctx.fiber.dispose()
})
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
const { events } = await bootRegistries()
const definition = eventDefinition('message')
@@ -85,6 +85,7 @@ interface BenchOptions {
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
footer?: React.ReactNode
attachments?: readonly ComposerAttachment[]
addImages?: (files: readonly File[]) => string | null
commandMenuOpen?: boolean
@@ -145,6 +146,10 @@ function bench(over?: BenchOptions) {
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
if (key === 'conversation.input.overlay') return over?.overlay ?? null
if (key === 'conversation.input.left') return over?.leftItems ?? null
if (key === 'conversation.input.right') return over?.rightItems ?? null
if (key === 'conversation.composer.dock') return over?.footer ?? null
if (key === 'conversation.input.plan') return over?.planEntry ?? null
if (key === 'conversation.input.model') return over?.modelEntry ?? null
return null
@@ -196,9 +201,6 @@ function bench(over?: BenchOptions) {
...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}),
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector<HTMLDivElement>('[data-composer-input]')!
@@ -1278,15 +1280,17 @@ describe('strips and variants', () => {
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders overlay anchor and left/right slot items', () => {
it('renders overlay, left/right, and footer slots at their layout positions', () => {
const { view } = bench({
overlay: <i data-testid="ov" />,
leftItems: <i data-testid="li" />,
rightItems: <i data-testid="ri" />,
footer: <i data-testid="foot" />,
})
expect(view.getByTestId('ov')).toBeTruthy()
expect(view.getByTestId('li')).toBeTruthy()
expect(view.getByTestId('ri')).toBeTruthy()
expect(view.getByTestId('foot')).toBeTruthy()
})
})
@@ -1299,7 +1303,10 @@ describe('command launcher chrome and control seats', () => {
// Every seat dispatched, nothing rendered (render passes may repeat; the
// seat set is the contract).
expect([...new Set(slotCalls.map(c => c.key))]).toEqual([
'conversation.input.attachments', 'conversation.input.plan', 'conversation.input.model',
'conversation.input.overlay', 'conversation.input.attachments',
'conversation.input.plan', 'conversation.input.left',
'conversation.input.right', 'conversation.input.model',
'conversation.composer.dock',
])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
@@ -1465,12 +1472,13 @@ describe('command launcher chrome and control seats', () => {
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
const controls = slotCalls.filter(call => call.key !== 'conversation.input.attachments')
const controlKeys = new Set(['conversation.input.plan', 'conversation.input.model'])
const controls = slotCalls.filter(call => controlKeys.has(call.key))
expect(controls.every(c => (c.owner as { locked: boolean }).locked)).toBe(true)
expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false)
cleanup()
const live = bench({ running: true })
const liveControls = live.slotCalls.filter(call => call.key !== 'conversation.input.attachments')
const liveControls = live.slotCalls.filter(call => controlKeys.has(call.key))
expect(liveControls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true)
expect(attachmentOwner(live.slotCalls).canAcceptDrop).toBe(true)
})
@@ -328,6 +328,25 @@ describe('Hero chrome', () => {
})
describe('ConversationRoot resident composer', () => {
it('does not redispatch composer child slots for an unrelated Session publication', () => {
const b = mount(sessionSnapshotOf())
const childKeys = new Set([
'conversation.input.overlay',
'conversation.input.left',
'conversation.input.right',
'conversation.composer.dock',
])
const dispatchCount = () => b.slotCalls.filter(key => childKeys.has(key)).length
const before = dispatchCount()
act(() => {
const current = b.session.getSnapshot()
b.session.set({ ...current, hasMore: !current.hasMore })
})
expect(dispatchCount()).toBe(before)
})
it('renders the composer inert with the blocker\u2019s own reason', () => {
const b = mount(sessionSnapshotOf(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },
@@ -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-deliverables/README.md
README.md: ac56ae782ac13664fdfb32c51ea07e2f8f9818d7
README.zh.md: 1aefe0d3c94f3d1c93c193426fd3d21b50359c75
README.md: b228106848549dcddcde84be07c47392dc92a9db
README.zh.md: 0a9d5207fe3c09b89ac34592f04c7eeaeed9b59a
+2 -2
View File
@@ -25,11 +25,11 @@ This package renders the deliverables row a finished turn ends with — the file
<a id="use-this-package"></a>
## Use this package
Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and a **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`.
Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and an omitted-file **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`.
### The row
The row shows the largest leading prefix that fits — up to six chips, basename text with the full path as the title — reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling.
The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
### Inline-code links
+2 -2
View File
@@ -25,11 +25,11 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,只有页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。
与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,有文件被省略、页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。
### 该行
该行展示能放下的最大前缀——至多六个标签项,文本为文件名、完整路径作为 `title`——并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。
该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
### 行内代码链接
@@ -1,13 +1,10 @@
/* Turn-tail produced-files summary: one measured chip lane plus an optional
native-folder action below it. */
/* Turn-tail produced-files summary with an optional native-folder action. */
.root {
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
align-items: start;
column-gap: 8px;
row-gap: 6px;
margin-top: 16px;
font-size: 13px;
line-height: 22px;
@@ -19,22 +16,33 @@
color: var(--dsw-alias-label-tertiary);
}
.row {
.lane {
--produced-file-chip-max: 96px;
--produced-file-gap: 8px;
grid-column: 2;
grid-row: 1;
display: grid;
row-gap: 6px;
min-width: 0;
container-type: inline-size;
}
.row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
gap: var(--produced-file-gap);
min-width: 0;
overflow: hidden;
}
/* One produced file. A link by behavior (it opens the file), a chip by shape:
full paths are long, while the measured lane stays on one row. */
/* File names yield space to one another while the remainder stays readable. */
.file {
flex: 0 0 auto;
max-width: 320px;
box-sizing: border-box;
flex: 0 1 auto;
min-width: 0;
max-width: var(--produced-file-chip-max);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -62,13 +70,120 @@
/* Overflow count: the row never silently drops files it did not show. */
.more {
flex: 0 0 auto;
display: none;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.more[data-shown='6'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='6']) > .showFolder {
display: block;
}
/* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */
@container (max-width: 687px) {
.file:nth-of-type(6),
.more[data-shown='6'] {
display: none;
}
.more[data-shown='5'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='5']) > .showFolder {
display: block;
}
}
@container (max-width: 583px) {
.file:nth-of-type(5),
.more[data-shown='5'] {
display: none;
}
.more[data-shown='4'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='4']) > .showFolder {
display: block;
}
}
@container (max-width: 479px) {
.file:nth-of-type(4),
.more[data-shown='4'] {
display: none;
}
.more[data-shown='3'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='3']) > .showFolder {
display: block;
}
}
@container (max-width: 375px) {
.file:nth-of-type(3),
.more[data-shown='3'] {
display: none;
}
.more[data-shown='2'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='2']) > .showFolder {
display: block;
}
}
@container (max-width: 271px) {
.file:nth-of-type(2),
.more[data-shown='2'] {
display: none;
}
.more[data-shown='1'] {
display: inline;
}
.showFolder {
display: none;
}
.lane:has(.more[data-shown='1']) > .showFolder {
display: block;
}
}
.showFolder {
grid-column: 2;
grid-row: 2;
justify-self: start;
margin: 0;
padding: 0 2px;
@@ -85,21 +200,3 @@
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
}
/* Exact browser-native probes for every candidate shown count. They share the
visible styles but never affect layout, accessibility, or scroll width. */
.measure {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
contain: strict;
}
.probe {
position: absolute;
inset: 0 auto auto 0;
width: max-content;
}
@@ -1,44 +1,13 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useEffect } from 'react'
import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** At most six chips compete for the one-line summary; every other path stays counted. */
/** Maximum number of file chips rendered before the remainder counter. */
const SHOWN_LIMIT = 6
/**
* Select the largest prefix whose measured chips and exact remainder fit.
* @param available - usable width of the one-line file lane.
* @param gap - computed flex gap between adjacent visible items.
* @param chipWidths - measured widths for the candidate file chips.
* @param moreWidthsByShown - exact localized remainder width for each shown count.
* @returns Number of leading chips to render.
*/
export function fitProducedFiles(
available: number,
gap: number,
chipWidths: readonly number[],
moreWidthsByShown: readonly (number | undefined)[],
): number {
if (available <= 0) return chipWidths.length
const prefix = [0]
let prefixWidth = 0
for (const width of chipWidths) {
prefixWidth += width
prefix.push(prefixWidth)
}
let largestFit = 0
for (const [shown, width] of prefix.entries()) {
const more = moreWidthsByShown[shown]
const items = shown + (more === undefined ? 0 : 1)
const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap
if (needed <= available) largestFit = shown
}
return largestFit
}
/** Registration-side Host capability facts. */
export interface ProducedFilesInjected {
/** Whether the browser itself is connected over loopback. */
@@ -71,81 +40,46 @@ export function ProducedFiles({
useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen])
const hostCanOpenPath = useWorkspacePathOpen(available => available === true)
const canOpenPath = isLoopback && hostCanOpenPath
const limit = Math.min(paths.length, SHOWN_LIMIT)
const [shownCount, setShownCount] = useState(limit)
const rowRef = useRef<HTMLDivElement>(null)
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
const moreProbe = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const row = rowRef.current
const remainderProbe = moreProbe.current
/* v8 ignore next -- React attaches both refs before the layout effect runs. */
if (row === null || remainderProbe === null) return
const measure = (): void => {
const styles = getComputedStyle(row)
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
// React attaches every still-mounted callback ref before layout effects run.
const activeChipProbes = chipProbes.current.slice(0, limit) as HTMLButtonElement[]
const chips = activeChipProbes.map(probe => probe.getBoundingClientRect().width)
const more = Array.from({ length: limit + 1 }, (_, candidate) => {
if (paths.length === candidate) return undefined
remainderProbe.textContent = moreLabel(t, paths.length - candidate)
return remainderProbe.getBoundingClientRect().width
})
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
}
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(row)
for (const probe of [...chipProbes.current, moreProbe.current]) {
if (probe !== null) observer.observe(probe)
}
return () => { observer.disconnect() }
}, [limit, paths, t])
const visibleCount = Math.min(shownCount, limit)
const shown = paths.slice(0, visibleCount)
const hidden = paths.length - shown.length
const shown = paths.slice(0, SHOWN_LIMIT)
return (
<div className={css.root}>
<span className={css.label}>{t('produced.label')}</span>
<div ref={rowRef} className={css.row} data-produced-files-row>
{shown.map(path => (
<div className={css.lane}>
<div className={css.row} data-produced-files-row>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
</button>
))}
{shown.map((_, index) => {
const shownCount = index + 1
const remainder = paths.length - shownCount
if (remainder <= 0) return null
return (
<span key={shownCount} className={css.more} data-shown={shownCount}>
{moreLabel(t, remainder)}
</span>
)
})}
</div>
{paths.length > 1 && canOpenPath && (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
className={css.showFolder}
onClick={() => { openFile('.') }}
>
{basename(path)}
{t('produced.showInFolder')}
</button>
))}
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
</div>
{hidden > 0 && canOpenPath && (
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
{t('produced.showInFolder')}
</button>
)}
<div className={css.measure} aria-hidden="true">
{paths.slice(0, limit).map((path, index) => (
<button
key={path}
ref={(node) => { chipProbes.current[index] = node }}
type="button"
tabIndex={-1}
className={`${css.file} ${css.probe}`}
>
{basename(path)}
</button>
))}
<span ref={moreProbe} className={`${css.more} ${css.probe}`} />
)}
</div>
</div>
)
@@ -177,14 +177,19 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
? context.state
: { ...context.state, produced: [...context.state.produced, { seq: match.event.seq, path }] }
},
buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined
? null
: {
buildLocationData: (context, scope, previous) => {
if (scope !== 'turn' || context.state === undefined) return null
if (previous?.kind === 'turn'
&& previous.turn === context.state.turn
&& previous.key === 'deliverables'
&& previous.value.produced === context.state.produced) return previous
return {
kind: 'turn',
turn: context.state.turn,
key: 'deliverables',
value: { produced: context.state.produced },
},
}
},
}
/**
@@ -6,14 +6,14 @@
* (HMR safety) against the real SlotRegistry.
*/
import { Context } from '@deepseek-ai/cordis'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import {
ConversationNodeAssembler, UiConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationLocationDataSource, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationStartMatch, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -21,9 +21,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import {
fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps,
} from '../src/client/ProducedFiles.tsx'
import { ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps } from '../src/client/ProducedFiles.tsx'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
type DeliverablesTurnData,
@@ -32,21 +30,15 @@ import { apply, inject } from '../src/client/index.ts'
import { en, zh } from '../src/client/locales.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
if (originalClientWidth === undefined) {
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
} else {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
})
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
private readonly sources = new Map<string, ConversationLocationDataSource<unknown>>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
@@ -54,6 +46,17 @@ class TestTurnDataStore implements ConversationLocationDataStore<ConversationTur
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
source<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): ConversationLocationDataSource<Readonly<ConversationTurnDataMap[Key]> | undefined> {
let source = this.sources.get(key)
if (source === undefined) {
source = { getSnapshot: () => this.get(key), subscribe: () => () => {} }
this.sources.set(key, source)
}
return source as ConversationLocationDataSource<Readonly<ConversationTurnDataMap[Key]> | undefined>
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
value: ConversationTurnDataMap[Key],
@@ -391,6 +394,9 @@ describe('produced-file Turn data', () => {
value.append(call(4, 'second', 'edit', {
file_path: 'second.txt', old_string: 'before', new_string: 'after',
}))
value.flush()
expect(deliverablesOf(value)).toBe(first)
value.append(result(5, 'second'))
value.flush()
expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt'])
@@ -410,48 +416,9 @@ describe('ProducedFiles row', () => {
}
}
it('selects the largest prefix using the exact remainder width', () => {
expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
// A zero-width lane is a pre-layout test/hidden state, not evidence that
// every chip overflowed; keep the bounded initial prefix until measured.
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
expect(fitProducedFiles(128, 8, [60, 60], [70, 50, undefined])).toBe(2)
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
})
it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
it('renders the bounded CSS candidates and opens a file or the workspace folder', () => {
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts']
const openFile = vi.fn<(path: string) => void>()
let available = 226
let resize: ResizeObserverCallback | undefined
const disconnect = vi.fn()
const observeNode = vi.fn<(target: Element) => void>()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resize = callback }
observe(target: Element): void {
expect(target).toBeInstanceOf(Element)
observeNode(target)
}
disconnect(): void { disconnect() }
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
})
const rect = (width: number): DOMRect => ({
x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
toJSON: () => ({}),
})
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getProbeRect(this: HTMLElement) {
if (this.closest('[aria-hidden="true"]') === null) return rect(0)
if (this.tagName !== 'BUTTON') return rect(60)
return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
})
const view = render(
<ProducedFiles matched={paths} openFile={openFile} {...capability(true)} t={t} />,
@@ -459,9 +426,8 @@ describe('ProducedFiles row', () => {
expect(view.getByText('产物')).toBeTruthy()
const row = view.container.querySelector('[data-produced-files-row]')
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
// The third probe is 100px: two chips plus the remainder fit, three do not.
expect(within(row).getAllByRole('button')).toHaveLength(2)
expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
expect(within(row).getAllByRole('button')).toHaveLength(6)
expect(within(row).getByText('+ 2 个文件')).toBeTruthy()
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
expect(chip.textContent).toBe('a.html')
expect(chip.getAttribute('title')).toBe('deep/a.html')
@@ -472,30 +438,6 @@ describe('ProducedFiles row', () => {
const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
fireEvent.click(showFolder)
expect(openFile).toHaveBeenLastCalledWith('.')
available = 150
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
// A missing/unsupported computed gap falls back to zero rather than NaN.
vi.stubGlobal('getComputedStyle', () => ({ columnGap: '', gap: '' } as CSSStyleDeclaration))
available = 165
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(2)
// Ref callbacks leave nulls in the probe arrays when the candidate set
// shrinks; the replacement observer must skip those stale slots.
observeNode.mockClear()
view.rerender(
<ProducedFiles matched={paths.slice(0, 1)} openFile={openFile} {...capability(true)} t={t} />,
)
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(observeNode).toHaveBeenCalledTimes(3)
view.unmount()
expect(disconnect).toHaveBeenCalledTimes(2)
bounds.mockRestore()
})
it('keeps the folder action absent without overflow or a local native opener', () => {
@@ -5,7 +5,7 @@
import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
type ChainRenderOpts, type HostObservable, type KeyedStandardSource, type LocaleFace, type RenderOpts,
type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
type StoredEntry, type Translate,
@@ -109,22 +109,28 @@ function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefine
const args: unknown[] = []
if (binding !== undefined) args.push(binding.key)
if (actions !== undefined) args.push(actions)
return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
return bindInjectSources((inject as (...args: unknown[]) => InjectedProps)(...args))
}
/**
* Normalize one entry-owned inject face on its existing cache axis. Its hooks
* compartment remains the original Observable-only contract.
*/
function bindInjectHooks(face: InjectedProps): InjectedProps {
/** Bind one entry-owned inject face on its existing cache axis. */
function bindInjectSources(face: InjectedProps): InjectedProps {
const sources = face['hooks']
if (sources === undefined) return face
const { hooks: _hooks, ...rest } = face
const keyedSources = face['keyedHooks']
if (sources === undefined && keyedSources === undefined) return face
const { hooks: _hooks, keyedHooks: _keyedHooks, ...rest } = face
const bound: InjectedProps = rest
for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
for (const [name, source] of Object.entries(
(sources ?? {}) as Record<string, HostObservable<unknown>>,
)) {
const hookName = standardHookPropName(name)
bound[hookName] = observableHook(source)
}
for (const [name, source] of Object.entries(
(keyedSources ?? {}) as Record<string, KeyedStandardSource>,
)) {
const hookName = standardHookPropName(name)
bound[hookName] = keyedObservableHook(source)
}
return bound
}
@@ -930,6 +930,41 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(seen.at(-1)!['read']).toBe('hot')
})
it('binds the inject keyedHooks compartment without exposing source resolvers', () => {
const h = makeHost()
h.declare('k.single', SINGLE_ROOT)
const badges = new Map([
['first', observable('cold')],
['second', observable('idle')],
])
const seen: Record<string, unknown>[] = []
h.add('k.single', {
component: (props: {
useBadge?: (key: string) => string | undefined
keyedHooks?: unknown
plain?: string
}) => {
seen.push({
keyedHooks: props.keyedHooks,
plain: props.plain,
read: props.useBadge!('first'),
})
return null
},
inject: () => ({
plain: 'kept',
keyedHooks: { badge: (key: string) => badges.get(key) },
}),
})
mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
expect(seen.at(-1)).toEqual({ keyedHooks: undefined, plain: 'kept', read: 'cold' })
act(() => { badges.get('second')!.set('ignored') })
expect(seen).toHaveLength(1)
act(() => { badges.get('first')!.set('hot') })
expect(seen.at(-1)!['read']).toBe('hot')
})
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
+35 -2
View File
@@ -17,7 +17,7 @@ import type { ReactNode } from 'react'
import type {
BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl,
} from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from './renderer.ts'
import type { HostObservable, KeyedStandardSource } from './renderer.ts'
export * from './store.ts'
export * from './renderer.ts'
@@ -375,6 +375,26 @@ export type SlotComponent<P> = (props: P) => ReactNode
*/
export type HooksSources = Record<string, HostObservable<unknown>>
/** Registrant keyed-hooks compartment: stable key-to-observable resolvers. */
export type KeyedHooksSources = Record<string, KeyedStandardSource>
/** Selector Hook over an open family of keyed observable sources. */
export type KeyedSnapshotSelectorHook<Snapshot> = {
/** @param key - source key. @returns the current value, or absence when the source is unavailable. */
(key: string): Snapshot | undefined
/**
* @param key - source key.
* @param selector - projection over the current keyed value.
* @param equal - optional selected-value equality.
* @returns the selected value.
*/
<Selected>(
key: string,
selector: (value: Snapshot | undefined) => Selected,
equal?: (left: Selected, right: Selected) => boolean,
): Selected
}
/** Framework-owned props visible while a slot-level contextual Hook is bound. */
export type StandardPropsOf<K extends keyof SlotMap & string> =
(ScopeOf<K> extends 'session' ? SessionStandardProps
@@ -421,13 +441,26 @@ export type PropsHooks<HS extends HooksSources> = {
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
}
/** Selector-hook share synthesized from an entry inject keyed-hooks compartment. */
export type PropsKeyedHooks<HS extends KeyedHooksSources> = {
[N in keyof HS & string as `use${Capitalize<N>}`]: KeyedSnapshotSelectorHook<
HS[N] extends (key: string) => HostObservable<infer T> | undefined ? T : never
>
}
/**
* The component-side view of an inject face: the reserved `hooks`
* compartment (when declared) arrives as bound `use<Name>` selector hooks;
* every other member passes through verbatim.
*/
export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
I extends { hooks: infer HS extends HooksSources }
? I extends { keyedHooks: infer KS extends KeyedHooksSources }
? Omit<I, 'hooks' | 'keyedHooks'> & PropsHooks<HS> & PropsKeyedHooks<KS>
: Omit<I, 'hooks'> & PropsHooks<HS>
: I extends { keyedHooks: infer KS extends KeyedHooksSources }
? Omit<I, 'keyedHooks'> & PropsKeyedHooks<KS>
: I
/**
* The composed component props intersection: runtime share (SlotMap) +
@@ -50,6 +50,8 @@ export function toolChatSnapshot(
order: nodes.map(node => node.key),
nodes: {
get: key => byKey.get(key),
source: key => ({ getSnapshot: () => byKey.get(key), subscribe: () => () => {} }),
processSource: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }),
values: () => nodes,
},
locations: {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: 02daa3a7e4440b4ea4901f98139044bbda68827f
README.zh.md: 0a1aa49a94d68cc1ab56e02831e4cbf06be5fdb1
README.md: d947787c123fca77dc7978456f4cfdc4e99951bf
README.zh.md: 3b281c6c1e90108d66f050e07a3b08208ed8c802
+2 -2
View File
@@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit
### Inspecting records
Selection, timeline navigation, folding, search, and Request totals cover the loaded window. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
### The timing overview
@@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
### Virtual rows
Long ledgers mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
### Layout
+2 -2
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 检查记录
选择、时间线导航、折叠、搜索与请求汇总只覆盖已加载的窗口。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
### 时间概览
@@ -47,7 +47,7 @@ kind: "package-reference"
### 虚拟行
长记录表只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
### 布局
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
AssistantBlock, AssistantMessageNode, ConvViewProps, MessageImageLoader, RenderMessageImages,
ToolCallBlock,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -30,6 +31,14 @@ import css from './views.module.css'
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
const SEARCH_INDEX_THROTTLE_MS = 3_000
const HISTORY_PAGE_NODES = 50
function containsCall(calls: readonly ToolCallBlock[], callId: string): boolean {
for (const call of calls) {
if (call.callId === callId || containsCall(call.subCalls, callId)) return true
}
return false
}
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
@@ -146,10 +155,40 @@ export function TrajectoryView({
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useTrajectory(snapshot => snapshot)
const completeInspection = useTrajectory(snapshot => snapshot)
const latestNodeSeq = completeInspection.eventNodes.at(-1)?.seq
const [historyTailSeq, setHistoryTailSeq] = useState(latestNodeSeq)
const [historyNodeLimit, setHistoryNodeLimit] = useState(HISTORY_PAGE_NODES)
const fixedTailSeq = historyTailSeq ?? latestNodeSeq
const historyTailIndex = fixedTailSeq === undefined
? -1
: completeInspection.eventNodes.findLastIndex(node => node.seq <= fixedTailSeq)
const historyEndIndex = historyTailIndex < 0 && latestNodeSeq !== undefined
? completeInspection.eventNodes.length
: historyTailIndex + 1
const historyStartIndex = Math.max(0, historyEndIndex - historyNodeLimit)
useEffect(() => {
if (latestNodeSeq !== undefined && (historyTailSeq === undefined || historyTailIndex < 0)) {
setHistoryTailSeq(latestNodeSeq)
}
}, [historyTailIndex, historyTailSeq, latestNodeSeq])
const inspection = useMemo<TrajectorySnapshot>(() => {
if (historyStartIndex === 0) return completeInspection
const eventNodes = completeInspection.eventNodes.slice(historyStartIndex)
const firstSeq = eventNodes[0]?.seq ?? 0
return {
...completeInspection,
eventNodes,
requests: completeInspection.requests.filter(request =>
request.startSeq >= firstSeq || (request.resultSeq ?? -1) >= firstSeq),
}
}, [completeInspection, historyStartIndex])
const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
const sessionHasOlderHistory = useSession(snapshot => snapshot.hasMore)
const hasResidentOlderHistory = historyStartIndex > 0
const hasOlderHistory = hasResidentOlderHistory
|| sessionHasOlderHistory
const nodes = inspection.eventNodes
const eventLocations = inspection.eventLocations
const historyBaseSeq = nodes[0]?.seq ?? 0
@@ -158,14 +197,24 @@ export function TrajectoryView({
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const inspectCallId = viewRequest?.view === 'trajectory' ? viewRequest.focus : null
const inspectNodeIndex = useMemo(() => inspectCallId === null
? -1
: completeInspection.eventNodes.findIndex(node => node.kind === 'assistant'
? node.blocks.some(block => block.kind === 'tool-call' && block.callId === inspectCallId)
: node.kind === 'tool-result' && containsCall([node], inspectCallId)),
[completeInspection.eventNodes, inspectCallId])
useEffect(() => {
if (inspectNodeIndex < 0 || inspectNodeIndex >= historyStartIndex) return
setHistoryNodeLimit(limit => limit + historyStartIndex - inspectNodeIndex)
}, [historyStartIndex, inspectNodeIndex])
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const node of nodes) {
for (const node of completeInspection.eventNodes) {
if (node.kind !== 'assistant' || node.step <= 0) continue
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
}
const requestsByStep = new Map(
requests
completeInspection.requests
.filter(request => request.purpose === 'assistant')
.map(request => [
`${request.turn}\u0000${request.step}`,
@@ -173,7 +222,7 @@ export function TrajectoryView({
]),
)
const orderedRequests = [
...requests.map(request => ({
...completeInspection.requests.map(request => ({
seq: request.startSeq,
request,
node: request.purpose === 'assistant'
@@ -257,7 +306,7 @@ export function TrajectoryView({
return numbered
}, [
nodes, requests, t,
completeInspection.eventNodes, completeInspection.requests, t,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
@@ -448,9 +497,11 @@ export function TrajectoryView({
})
}
const loadEarlierHistory = useCallback(() => {
return loadOlder()
}, [loadOlder])
const loadEarlierHistory = useCallback(async () => {
if (!hasResidentOlderHistory && !await loadOlder()) return false
setHistoryNodeLimit(limit => limit + HISTORY_PAGE_NODES)
return true
}, [hasResidentOlderHistory, loadOlder])
return (
<div className={css.root} data-conversation-composer-overlay="">
@@ -31,6 +31,9 @@ interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
}
const EMPTY_LOCATION_DATA_SOURCE = { getSnapshot: () => undefined, subscribe: () => () => {} }
const EMPTY_LOCATION_DATA = { get: () => undefined, source: () => EMPTY_LOCATION_DATA_SOURCE }
afterEach(cleanup)
describe('TrajectoryTurnHeader', () => {
@@ -271,7 +274,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'second step' }],
},
] as unknown as LegacyConversationSlice['nodes']
const data = { get: () => undefined }
const data = EMPTY_LOCATION_DATA
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
const turn = {
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
@@ -303,7 +306,7 @@ describe('deriveTrajectoryLayout', () => {
kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
content: [{ type: 'text', text: 'change direction' }], source: null,
}] as unknown as LegacyConversationSlice['nodes']
const data = { get: () => undefined }
const data = EMPTY_LOCATION_DATA
const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
const turn = {
turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
@@ -5,6 +5,9 @@ import type {
} from '../src/client/trajectory-contract.ts'
import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts'
const EMPTY_LOCATION_DATA_SOURCE = { getSnapshot: () => undefined, subscribe: () => () => {} }
const EMPTY_LOCATION_DATA = { get: () => undefined, source: () => EMPTY_LOCATION_DATA_SOURCE }
function assistantRequest(startSeq: number, step: number): Extract<RequestView, { purpose: 'assistant' }> {
return {
purpose: 'assistant',
@@ -30,7 +33,7 @@ function contribution(
}
function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] {
const data = { get: () => undefined }
const data = EMPTY_LOCATION_DATA
const stepLocation = {
turn,
step,
@@ -8,7 +8,7 @@
* Timeline projection and inclusive focus edge cases ride along.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -66,6 +66,8 @@ const runtimes: SlotTestRuntime[] = []
afterEach(async () => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
for (const runtime of runtimes.splice(0)) await runtime.dispose()
})
// The Conversation store persists under its declared key; clear so one case's active
@@ -1264,6 +1266,180 @@ describe('timeline projection', () => {
})
describe('TrajectoryView state', () => {
it('reveals resident history one bounded page at a time', async () => {
const nodes: LegacyConversationSlice['nodes'] = Array.from({ length: 5_000 }, (_, index) => ({
kind: 'user' as const,
seq: index + 1,
time: index + 1,
content: [{ type: 'text' as const, text: `prompt ${String(index + 1)}` }],
source: null,
}))
const trajectory = createSnapshotStore(historySnapshot([]))
const loadOlder = vi.fn(() => Promise.resolve(false))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneHistory(historySnapshot(nodes))}
{...standaloneDuration()}
useTrajectory={bindSnapshotSelector(trajectory)}
loadOlder={loadOlder}
/>,
)
act(() => { trajectory.set(historySnapshot(nodes)) })
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
fireEvent.click(screen.getAllByRole('button', { name: '加载更早的历史' }).at(-1)!)
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('101')
expect(loadOlder).not.toHaveBeenCalled()
act(() => { trajectory.set(historySnapshot([...nodes, {
kind: 'user', seq: 5_001, time: 5_001,
content: [{ type: 'text', text: 'appended prompt' }], source: null,
}])) })
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('102')
})
it('keeps a replacement window bounded when its predecessor tail is absent', () => {
const nodes = (start: number): LegacyConversationSlice['nodes'] => Array.from(
{ length: 100 },
(_, index) => ({
kind: 'user' as const,
seq: start + index,
time: start + index,
content: [],
source: null,
}),
)
const trajectory = createSnapshotStore(historySnapshot(nodes(1)))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneHistory(historySnapshot([]))}
{...standaloneDuration()}
useTrajectory={bindSnapshotSelector(trajectory)}
/>,
)
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
act(() => { trajectory.set(historySnapshot(nodes(201))) })
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('51')
})
it('keeps resident request numbering and cumulative usage outside the layout page', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
const visibleNodes: LegacyConversationSlice['nodes'] = Array.from(
{ length: 10 },
(_, index) => ({
kind: 'assistant' as const,
seq: index * 2 + 44,
time: index * 2 + 44,
turn: index + 2,
step: 1,
blocks: [{ kind: 'text' as const, text: `visible response ${String(index + 1)}` }],
}),
)
const nodes: LegacyConversationSlice['nodes'] = [{
kind: 'assistant', seq: 2, time: 2, turn: 1, step: 1,
blocks: [{ kind: 'tool-call', callId: 'boundary-call', name: 'bash', argsRaw: '{}' }],
}, {
kind: 'tool-result', seq: 3, time: 3, callId: 'boundary-call',
call: { name: 'bash', argsRaw: '{}' }, callTime: 2,
content: [], isError: false, subCalls: [],
}, ...Array.from({ length: 39 }, (_, index) => ({
kind: 'compaction' as const,
seq: index + 4,
time: index + 4,
summary: null,
summaryEventSeq: null,
shadowedItemCount: null,
shadowedTokenCount: null,
})), ...visibleNodes]
const requests: readonly RequestView[] = [{
purpose: 'assistant', startSeq: 1, resultSeq: 2, startedAt: 1, completedAt: 2,
status: 'complete', turn: 1, step: 1, usage: { inputTokens: 1 },
}, ...visibleNodes.map((node, index) => ({
purpose: 'assistant' as const,
startSeq: node.seq - 1,
resultSeq: node.seq,
startedAt: node.seq - 1,
completedAt: node.seq,
status: 'complete' as const,
turn: index + 2,
step: 1,
usage: { inputTokens: 1 },
}))]
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneHistory(historySnapshot(nodes, { requests }))}
{...standaloneDuration()}
/>,
)
await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #1' })).toBeTruthy() })
await waitFor(() => { expect(screen.getByRole('button', { name: '请求 #11' })).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: '请求 #11' }))
fireEvent.click(screen.getByRole('tab', { name: '用量' }))
expect(screen.getByText('会话累计').closest('section')?.textContent).toContain('11 tok')
})
it.each([
['root', {
kind: 'assistant' as const,
seq: 3,
time: 3,
turn: 1,
step: 1,
blocks: [{ kind: 'tool-call' as const, callId: 'hidden-root', name: 'bash', argsRaw: '{}' }],
}, 'hidden-root'],
['nested', {
kind: 'tool-result' as const,
seq: 3,
time: 3,
callId: 'hidden-root',
call: { name: 'run_code', argsRaw: '{}' },
callTime: 2,
content: [],
isError: false,
subCalls: [{
callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}',
turn: 1, step: 1, time: 3, subCalls: [],
}],
}, 'hidden-child'],
])('reveals a hidden resident %s call for cross-view inspection', (_kind, target, focus) => {
const nodes: LegacyConversationSlice['nodes'] = [
{ kind: 'user', seq: 1, time: 1, content: [], source: null },
{
kind: 'tool-result', seq: 2, time: 2, callId: 'unrelated', call: null, callTime: null,
content: [], isError: false, subCalls: [],
},
target,
...Array.from({ length: 50 }, (_, index) => ({
kind: 'user' as const,
seq: index + 4,
time: index + 4,
content: [{ type: 'text' as const, text: `later ${String(index + 1)}` }],
source: null,
})),
]
const completeViewRequest = vi.fn()
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneHistory(historySnapshot(nodes))}
{...standaloneDuration()}
viewRequest={{ view: 'trajectory', focus }}
completeViewRequest={completeViewRequest}
/>,
)
expect(completeViewRequest).toHaveBeenCalledOnce()
})
it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore()
const commonProps = {
@@ -64,9 +64,15 @@ const conversationState: ConversationState = {
activeTargets: new Set(),
}
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
order: emptyKeys,
nodes: { get: () => undefined, values: () => [] },
nodes: {
get: () => undefined,
source: () => emptyNodeSource,
processSource: () => emptyNodeSource,
values: () => [],
},
locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
navigation: { items: () => [] },
timeline: { turnOrder: [], turns: new Map() },
@@ -63,9 +63,15 @@ const conversationState: ConversationState = {
activeTargets: new Set(),
}
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
order: emptyKeys,
nodes: { get: () => undefined, values: () => [] },
nodes: {
get: () => undefined,
source: () => emptyNodeSource,
processSource: () => emptyNodeSource,
values: () => [],
},
locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
navigation: { items: () => [] },
timeline: { turnOrder: [], turns: new Map() },
@@ -551,7 +551,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'InjectFace',
declaration: 'export type InjectFace<I extends object> = I extends {\n hooks: infer HS extends HooksSources;\n} ? Omit<I, \'hooks\'> & PropsHooks<HS> : I;',
declaration: 'export type InjectFace<I extends object> = I extends {\n hooks: infer HS extends HooksSources;\n} ? I extends {\n keyedHooks: infer KS extends KeyedHooksSources;\n} ? Omit<I, \'hooks\' | \'keyedHooks\'> & PropsHooks<HS> & PropsKeyedHooks<KS> : Omit<I, \'hooks\'> & PropsHooks<HS> : I extends {\n keyedHooks: infer KS extends KeyedHooksSources;\n} ? Omit<I, \'keyedHooks\'> & PropsKeyedHooks<KS> : I;',
},
{
name: 'InjectParams',
@@ -561,6 +561,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<RemoteResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RemoteResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RemoteResult<{\n title: string;\n seq: SessionSeq;\n }>>;\n loadOlder(): Promise<void>;\n loadThrough(seq: SessionSeq): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'KeyedHooksSources',
declaration: 'export type KeyedHooksSources = Record<string, KeyedStandardSource>;',
},
{
name: 'KeyedSnapshotSelectorHook',
declaration: 'export type KeyedSnapshotSelectorHook<Snapshot> = {\n (key: string): Snapshot | undefined;\n <Selected>(key: string, selector: (value: Snapshot | undefined) => Selected, equal?: (left: Selected, right: Selected) => boolean): Selected;\n};',
},
{
name: 'KeyedStandardSource',
declaration: 'export type KeyedStandardSource = (key: string) => HostObservable<unknown> | undefined;',
},
{
name: 'KeyPropsOf',
declaration: 'export type KeyPropsOf<K extends keyof SlotMap & string, EntryKey extends EntryKeyOf<K>> = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? EntryKey extends keyof P ? P[EntryKey] extends object ? P[EntryKey] : never : never : object;',
@@ -645,6 +657,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PropsHooks',
declaration: 'export type PropsHooks<HS extends HooksSources> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>;\n};',
},
{
name: 'PropsKeyedHooks',
declaration: 'export type PropsKeyedHooks<HS extends KeyedHooksSources> = {\n [N in keyof HS & string as `use${Capitalize<N>}`]: KeyedSnapshotSelectorHook<HS[N] extends (key: string) => HostObservable<infer T> | undefined ? T : never>;\n};',
},
{
name: 'PropsLocale',
declaration: 'export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string ? {\n t: TranslateNS<N>;\n} : object;',
@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:205',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:221',
},
{
key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:193',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
},
{
key: 'conversation.chat.node',
@@ -289,7 +289,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'useTrajectory: UseTrajectory',
],
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run',
hookContext: 'string',
hookContext: 'ConversationLocationDataStore<ConversationTurnDataMap> | undefined',
slotInject: 'ChatNodeTurnDataInjected',
declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
occupants: [
@@ -313,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:174',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:190',
},
{
key: 'conversation.chat.turnTail',
@@ -358,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:199',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
},
{
key: 'conversation.composer',
@@ -417,7 +417,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Resident composer body, including the no-Session inert state.',
registerOptions: [],
ownerProps: [
'/** Owner share of the resident composer bar. */\nexport interface ComposerBarOwnerProps {\n /** Hero uses centered placement; composer uses the active bottom placement. */\n variant: \'hero\' | \'composer\'\n /** A feature-owned reason that makes message input inert while leaving model selection live. */\n blocked?: { readonly reason: string }\n /** Lock all message actions while preserving the resident composer surface. */\n disabled?: boolean\n /** Whether the shared Workspace picker is expanded. */\n workspacePickerOpen?: boolean\n /** Open the Workspace picker from the inert composer surface. */\n onRequestWorkspace?: () => void\n placeholder?: string\n /** Optional content rendered above the composer surface. */\n accessory?: ReactNode\n /** Floating overlay content rendered inside the composer card. */\n overlay?: ReactNode\n /** Left-side input controls. */\n leftItems?: ReactNode\n /** Right-side input controls. */\n rightItems?: ReactNode\n /** Ambient content below the card. */\n footer?: ReactNode\n}',
'/** Owner share of the resident composer bar. */\nexport interface ComposerBarOwnerProps {\n /** Hero uses centered placement; composer uses the active bottom placement. */\n variant: \'hero\' | \'composer\'\n /** A feature-owned reason that makes message input inert while leaving model selection live. */\n blocked?: { readonly reason: string }\n /** Lock all message actions while preserving the resident composer surface. */\n disabled?: boolean\n /** Whether the shared Workspace picker is expanded. */\n workspacePickerOpen?: boolean\n /** Open the Workspace picker from the inert composer surface. */\n onRequestWorkspace?: () => void\n placeholder?: string\n /** Optional content rendered above the composer surface. */\n accessory?: ReactNode\n}',
],
ownerPropsReferences: [
'Workspace',
@@ -471,13 +471,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
'/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}',
],
ownerPropsReferences: [
'InputState',
'SessionSnapshot',
],
ownerProps: [],
ownerPropsReferences: [],
standardProps: [
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'useSessions: UseSessions',
@@ -495,7 +490,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-chat StatsLine id \'stats\'',
],
@@ -537,7 +532,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:211',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
},
{
key: 'conversation.hero.agentPreset',
@@ -775,13 +770,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
'/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}',
],
ownerPropsReferences: [
'InputState',
'SessionSnapshot',
],
ownerProps: [],
ownerPropsReferences: [],
standardProps: [
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'useSessions: UseSessions',
@@ -799,7 +789,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -886,7 +876,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-commands PopupSelectView id \'command-popup\'',
'client-ui-input-trigger MenuView id \'slash-menu\'',
@@ -957,13 +947,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
'/** Point-in-time owner values for composer extension entries. */\nexport interface InputZone {\n readonly session: SessionSnapshot\n readonly input: InputState\n}',
],
ownerPropsReferences: [
'InputState',
'SessionSnapshot',
],
ownerProps: [],
ownerPropsReferences: [],
standardProps: [
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'useSessions: UseSessions',
@@ -981,7 +966,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -1025,7 +1010,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:187',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:203',
},
{
key: 'conversation.session',