Merge pull request #4749 from deepseek-harness/feat/chat-presentation-channel

feat(ui-chat): restore independent work-detail presentation from #4565
This commit is contained in:
imccyu authored and GitHub committed 2026-09-21 00:10:18 +08:00
commit b173fe1c19
149 files changed
+2003 -715

No files matched your search

@@ -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/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md
2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229
2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7
@@ -0,0 +1,286 @@
# Agent Note: Chat presentation policy channel and process-group architecture
Status: proposed
English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md)
## Problem
At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output.
The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering.
The review identified implementation choices, not the product requirements, as the problem:
- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys.
- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers.
- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently.
- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field.
The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node.
This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes.
The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work.
## Goals and constraints
The following decisions were confirmed during design review and are inputs to this proposal.
| Number | Decision | Meaning |
|---|---|---|
| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes |
| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change |
| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further |
| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent |
| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here |
| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly |
| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair |
Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter.
## Viable approaches
There are five approaches to group-header identity and content ownership.
**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat.
**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer.
**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers.
**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives.
**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush.
## Alternatives considered
**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement.
**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive.
**Decision 3 rules out approach β.** See the table above.
**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note.
**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row.
Choose approach δ.
## Proposal
### Data flow
```
事件流
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
──▶ Turn 数据 step-groups:组列表,每组一个对象
──▶ Chat builder
├─ 物化组头节点:一组一个 key,data 即组对象
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
──▶ 组头渲染器:读自己节点的 data
展示策略
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
──▶ seat:组合 selector,选出的是自己的结论
```
The only engine change passes the Location data whose references changed during a flush to the builder through `apply`.
### Responsibilities
| Layer | Owns | Explicitly does not own |
|---|---|---|
| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes |
| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure |
| Presentation policy object | Mapping the enum to capability fields | Node or seat state |
| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries |
| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content |
| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries |
### Header-node identity and lifecycle
A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true.
The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update.
A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`.
Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header.
### Fold state machine
The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty.
| Event | Transition |
|---|---|
| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set |
| Appended `tool/result` | Remove callId from the running set |
| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open |
| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step |
| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order |
| `turn/end` | Mark an open group as closed by the Turn end |
| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group |
Three invariants apply:
- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list.
- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced.
- Forward-only folding: do not read neighboring Contexts, nodes, or later history.
Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches.
Publication timing: batch live chunks at animation frames; publish other changes immediately.
### Membership rules
The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales.
| Node | Rule |
|---|---|
| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one |
| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism |
| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed |
| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group |
Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation.
The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it.
### Delivering group changes to the builder
The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee.
Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts.
### Combining hidden state in a seat
Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode:
```ts
declare function usePresentation<T>(select: (policy: {
stepGrouping: 'collapsed' | 'none'
foldCompletedTurns: boolean
}) => T): T
declare const group: { readonly headStep: number } | undefined
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
```
For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes.
Three sources combine into `hidden`:
| Source | Condition | Reveal action |
|---|---|---|
| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn |
| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group |
| Group header in Expanded | Header role and `grouping === 'none'` | None |
The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer.
The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion.
### Re-render scope
The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound.
| External change | May re-render |
|---|---|
| A running group receives a reasoning chunk | That assistant seat |
| A running group receives `tool/call` | That tool seat and the group's header |
| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups |
| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes |
| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning |
| Switch to or from Expanded | Only member and header seats |
| Change `performanceUsage` | Only turn-tail |
| Load an earlier page | New seats and historical seats whose presentation changes |
| Change locale | All seats, as a control case |
### Presentation policy object
Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`:
| Field | compact | detailed | expanded |
|---|---|---|---|
| `foldCompletedTurns` | true | true | true |
| `stepGrouping` | collapsed | collapsed | none |
| `settledReasoningPreview` | false | true | true |
The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable.
Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers.
### Per-Turn folding
Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change.
### Complexity requirements
| Scenario | Fold | Builder | Publication |
|---|---|---|---|
| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None |
| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn |
| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed |
| Mode change | None | None | None; seat and renderer selectors determine re-renders |
## Change inventory
| Package | File | Change |
|---|---|---|
| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` |
| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` |
| ui-conversation | `index.ts` | Export the new type |
| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` |
| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read |
| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable |
| ui-chat | `settings/TranscriptViewRow.tsx` | Three options |
| ui-chat | `locale.ts` | Mode labels and group-header copy |
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` |
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key |
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions |
| ui-chat | `conversation-nodes/common.ts` | Header anchor offset |
| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging |
| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership |
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation |
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store |
| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition |
| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` |
| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props |
| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning |
| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer |
| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step |
| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types |
This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work.
## Acceptance criteria
Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence.
**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus.
**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set.
**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages.
**Membership tests.** Given the group list and Turn nodes, assert each key's role.
**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate.
## Risks
- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice.
- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update.
- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them.
- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination.
- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work.
- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group.
## Retained alternative: parent-derived child Contexts
If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules.
This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express.
@@ -0,0 +1,286 @@
# Agent Note: Chat 展示策略通道与过程分组的基础架构
Status: proposed
[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文
## 问题
Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。
产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。
审阅那次实现,问题不在业务需求,在选型:
- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。
- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。
- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。
- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。
再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。
本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。
三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。
## 目标与约束
下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。
| 编号 | 裁决 | 含义 |
|---|---|---|
| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 |
| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 |
| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 |
| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 |
| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 |
| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 |
| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 |
有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。
## 可行的路线
围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。
**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。
**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。
**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。
**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。
**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。
## 备选方案与取舍
**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。
**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。
**路线 β 被裁决 3 否决。** 见上表。
**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。
**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。
选择路线 δ。
## 方案
### 数据流
```
事件流
──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件)
──▶ Turn 数据 step-groups:组列表,每组一个对象
──▶ Chat builder
├─ 物化组头节点:一组一个 key,data 即组对象
└─ 成员归属:按 step 映射到 key,写进 per-key presentation
──▶ seat:hidden = f(策略字段, 角色, 展开状态)
──▶ 组头渲染器:读自己节点的 data
展示策略
──▶ 渲染器:按 kind 注入的 hooks,选单个字段
──▶ seat:组合 selector,选出的是自己的结论
```
引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。
### 各层职责
| 层 | 拥有 | 明确不拥有 |
|---|---|---|
| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 |
| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 |
| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 |
| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 |
| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 |
| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 |
### 组头节点的身份与生命周期
组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。
组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。
组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。
组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。
### fold 状态机
`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。
| 事件 | 转移 |
|---|---|
| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 |
| `tool/result` 追加 | 从运行集合移除 callId |
| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 |
| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 |
| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 |
| `turn/end` | 有打开的组则标记为被轮次结束关闭 |
| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 |
三条不变量:
- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。
- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。
- 只向前折叠。不读邻居 Context,不读节点,不向后看。
Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。
发布节奏:live chunk 走动画帧合并,其余立即。
### 成员归属规则
归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。
| 节点 | 规则 |
|---|---|
| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 |
| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 |
| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 |
| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 |
归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。
组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。
### 分组变化怎样到达 builder
fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。
所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。
### seat 的 hidden 合成
seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论:
```ts
declare function usePresentation<T>(select: (policy: {
stepGrouping: 'collapsed' | 'none'
foldCompletedTurns: boolean
}) => T): T
declare const group: { readonly headStep: number } | undefined
const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping)
const foldCompleted = usePresentation(p => p.foldCompletedTurns)
```
不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。
三个隐藏来源合成一个 `hidden`:
| 来源 | 条件 | 揭示动作 |
|---|---|---|
| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 |
| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 |
| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 |
关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。
组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。
### 刷新范围
下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。
| 外部变化 | 允许重渲染的 |
|---|---|
| 运行中的组来一个推理 chunk | 该 assistant seat |
| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 |
| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 |
| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 |
| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 |
| 模式切到完全展开或切回 | 仅成员与组头 seat |
| `performanceUsage` 切换 | 仅 turn-tail |
| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat |
| 语言切换 | 全部,作为对照 |
### 展示策略对象
持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`:
| 字段 | compact | detailed | expanded |
|---|---|---|---|
| `foldCompletedTurns` | true | true | true |
| `stepGrouping` | collapsed | collapsed | none |
| `settledReasoningPreview` | false | true | true |
映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。
渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。
### 按轮折叠
per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。
### 复杂度契约
| 场景 | fold | builder | 发布 |
|---|---|---|---|
| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 |
| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key |
| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key |
| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 |
## 改动清单
| 包 | 文件 | 改动 |
|---|---|---|
| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` |
| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 |
| ui-conversation | `index.ts` | 导出新类型 |
| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` |
| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` |
| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 |
| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 |
| ui-chat | `locale.ts` | 三档文案;组头文案 |
| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` |
| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 |
| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` |
| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 |
| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 |
| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 |
| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 |
| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation |
| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key |
| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition |
| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` |
| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props |
| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 |
| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 |
| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 |
| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 |
上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。
## 验收标准
分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。
**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。
**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。
**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。
**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。
**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。
## 风险
- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。
- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。
- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。
- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。
- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。
- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。
## 备选保留:父 Context 派生子 Context
如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。
它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。
@@ -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/proposed/feature/2026-09-20-chat-work-details-migration.md
2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5
2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e
@@ -0,0 +1,144 @@
# Agent Note: Chat work-details migration progress
Status: proposed
English | [中文](2026-09-20-chat-work-details-migration.zh.md)
## Problem
PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements.
## Proposal
Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer.
This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion.
This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance.
### Status definitions
| Dimension | State | Meaning |
|---|---|---|
| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate |
| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation |
| Implementation | Existing capability | Already present in this channel |
| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance |
| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented |
| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet |
| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files |
| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated |
| Staging or commit | Staged | All pending code changes for the feature are staged but not committed |
| Staging or commit | Committed | Feature code is included in current HEAD |
| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute |
Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file.
### Capabilities already in this channel
| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance |
|---|---|---|---|---|---|---|---|
| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending |
| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending |
| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending |
| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending |
| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending |
| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending |
All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically.
### Migration inventory without grouping dependencies
“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below.
| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations |
|---|---|---|---|---|---|---|---|---|
| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not |
| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward |
| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics |
| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references |
| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields |
| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review |
| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review |
| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review |
| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged |
| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review |
| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review |
| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping |
The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature.
### Subsequent review batches
Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree.
| Batch | Features | Review scope |
|---|---|---|
| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending |
| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending |
| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification |
| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships |
| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible |
### Requirements retained for the grouping phase
All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements.
| Feature | compact | detailed | expanded | Current state |
|---|---|---|---|---|
| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) |
| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes |
| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) |
| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) |
| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) |
| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) |
| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets |
| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) |
Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments.
## Performance constraints
| Scenario | Property to preserve | Required evidence |
|---|---|---|
| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks |
| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count |
| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal |
| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations |
| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks |
| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks |
Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs.
## Alternatives considered
**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel.
**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation.
**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion.
**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly.
## Acceptance criteria
Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above.
| Acceptance item | Current state |
|---|---|
| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance |
| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete |
| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending |
| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation |
| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged |
| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) |
Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding.
## Risks
- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented.
- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control.
- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order.
- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer.
- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture.
@@ -0,0 +1,144 @@
# Agent Note: Chat 工作过程展示迁移进度
Status: proposed
[English](2026-09-20-chat-work-details-migration.md) | 中文
## 问题
PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。
## 方案
已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。
本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。
本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。
### 状态定义
| 维度 | 状态 | 含义 |
|---|---|---|
| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 |
| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 |
| 实现 | 当前代码已有 | 当前通道的既有基础能力 |
| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 |
| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 |
| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 |
| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 |
| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 |
| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 |
| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 |
| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 |
暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。
### 当前通道已有的能力
| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 |
|---|---|---|---|---|---|---|---|
| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 |
三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。
### 不依赖分组的迁移清单
下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。
| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 |
|---|---|---|---|---|---|---|---|---|
| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 |
| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 |
| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 |
| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 |
| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 |
| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 |
| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 |
| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 |
| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 |
| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 |
| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 |
| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 |
开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。
### 后续评审批次
评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。
| 批次 | 功能 | 评审范围 |
|---|---|---|
| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 |
| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 |
| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 |
| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 |
| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 |
### 分组阶段保留的需求
这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。
| 功能 | compact | detailed | expanded | 当前状态 |
|---|---|---|---|---|
| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) |
| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 |
| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) |
| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) |
| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) |
| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) |
| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 |
| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) |
分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。
## 性能约束
| 场景 | 要保留的性质 | 所需证据 |
|---|---|---|
| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 |
| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 |
| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 |
| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 |
| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 |
| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 |
保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。
## 备选方案与取舍
**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。
**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。
**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。
**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。
## 验收标准
每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。
| 验收项 | 当前状态 |
|---|---|
| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 |
| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 |
| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 |
| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 |
| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 |
| 分组功能与两档差异 | 未完成(分组阶段) |
验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。
## 风险
- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。
- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。
- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。
- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。
- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。
@@ -340,9 +340,9 @@ describe('web e2e: continuous conversation grown through the composer', () => {
))).toHaveLength(TURN_COUNT)
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1)
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0)
expect(await page.locator(
'[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
'[data-chat-flow-kind="system-prompt"]',
).count()).toBe(0)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.flatMap(event => (
@@ -9,14 +9,13 @@
- button "Jump to turn 1"
- button "Jump to turn 2"
- button "Jump to turn 3"
- button "System prompt"
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
- button "Copy"
- button "3 tool calls" [expanded]
- button "Context injection runtime-context"
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
- button "Think"
- button "Inspect plugins 0 dynamic plugins"
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
- button "Think"
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
- tablist "Plugin source":
- tab "Client"
@@ -27,11 +26,11 @@
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
- button "Inspect"
- button "Think The Host returned snap-1/pkg-1, so I will request its first activation."
- button "Think"
- text: Run Cordis Plugin snap-1 · pkg-1 Ready
- button "Inspect"
- text: snap-1/pkg-1 is awaiting user approval (run-1).
- button "Think The activation request has been submitted, so I will return the requested readiness marker."
- button "Think"
- paragraph: CORDIS_UI_READY
- button "Copy"
- button "Good response"
@@ -1,12 +1,10 @@
- button "System prompt"
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy"
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."
- button "Think"
- text: Failed
- 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
- text: Failed
- 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."'
- button "System prompt"
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
- button "Copy"
- button "Thought for a while"
@@ -16,7 +16,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- button "Thought for a while" [expanded]
- button "Context injection webhook github webhook handled by review-pr-when-ready"
- button "Context injection runtime-context"
@@ -16,7 +16,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- button "Thought for a while"
- paragraph: "Review complete: no actionable findings."
- button "Copy"
@@ -18,7 +18,7 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Conversation display Controls process content in completed turns
- text: px Work details Controls how turns and steps expand by default
- button "Compact"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
@@ -18,7 +18,7 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Conversation display Controls process content in completed turns
- text: px Work details Controls how turns and steps expand by default
- button "Compact"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
@@ -18,8 +18,8 @@
- text: 字号大小 仅影响会话内容的字号 14
- button "增大字号"
- button "减小字号"
- text: px 对话显示 控制已完成轮次的过程内容
- button "紧凑"
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
- button "简洁"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- button "/user-invoke-demo"
- button "meeting notes.md"
- text: and confirm the fixture wiring {{clock}}
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- button "/user-invoke-demo"
- button "meeting notes.md"
- text: and confirm the fixture wiring {{clock}}
@@ -6,11 +6,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- button "Context injection runtime-context"
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- button "Think"
- status: Deep diving...
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
@@ -6,12 +6,11 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- button "1 tool call" [expanded]
- button "Context injection runtime-context"
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- button "Think"
- button "Ask question 1/1 answered"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- button "1 tool call"
@@ -155,9 +155,9 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2)
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0)
expect(await page.locator(
'[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
'[data-chat-flow-kind="system-prompt"]',
).count()).toBe(0)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
+6 -11
View File
@@ -323,10 +323,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
const observedReasoning = Promise.withResolvers<undefined>()
const reasoningComplete = Promise.withResolvers<undefined>()
const releaseStream = MODE === 'record' ? undefined : scaffold.ctx.on('llm/stream', async function* (_options, next) {
let reasoning = false
for await (const chunk of next()) {
if (reasoning && chunk.type !== 'reasoning-delta') {
reasoningComplete.resolve(undefined)
await observedReasoning.promise
}
if (chunk.type === 'reasoning-delta') reasoning = true
@@ -338,17 +340,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
if (MODE !== 'record') {
const thinking = page.locator('[data-variant="think"][data-state="running"]')
await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
const liveTail = thinking.locator('[data-follow-end]')
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)
await reasoningComplete.promise
expect(await thinking.getAttribute('data-preview')).toBeNull()
expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false)
}
observedReasoning.resolve(undefined)
return await settled
+5 -1
View File
@@ -227,7 +227,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(
() => page.getByRole('button', { name: 'System prompt', exact: true }).count(),
{ timeout: 10_000 },
).toBe(2)
).toBe(0)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). Branch renders only under assistant answers — user
@@ -255,7 +255,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
const copy = page.getByRole('button', { name: 'Copy', exact: true }).last()
const composer = page.locator('[data-composer-seat]')
const tooltip = page.getByRole('tooltip', { name: 'Copy', exact: true })
const originalViewport = page.viewportSize()
if (originalViewport === null) throw new Error('tooltip probe requires a fixed viewport')
try {
await page.setViewportSize({ width: originalViewport.width, height: 600 })
// Grow the sticky seat upward so the bottom tooltip overlaps it without
// depending on the fixture's resting composer height.
await composer.evaluate((element) => { element.style.paddingTop = '48px' })
@@ -297,6 +300,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await page.mouse.move(0, 0)
if (await copy.count() > 0) await copy.evaluate((element) => { element.blur() })
if (await tooltip.count() > 0) await tooltip.waitFor({ state: 'hidden', timeout: 5_000 })
await page.setViewportSize(originalViewport)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+7 -2
View File
@@ -40,7 +40,7 @@ import {
buildVfsExampleFiles,
} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
import { expandOwningTurnProcess, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
@@ -418,12 +418,17 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
// Complete Turns can fold while earlier history is still unloaded.
const readTool = page.locator('[data-chat-call-id="preview-read"]')
await readTool.waitFor({ state: 'attached' })
expect(await readTool.isVisible()).toBe(false)
await expandOwningTurnProcess(page, readTool)
await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor()
await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor()
await page.getByText('Update to-do list', { exact: true }).waitFor()
await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
const subagents = page.getByRole('button', { name: '2 subagents' })
const subagents = page.getByRole('button', { name: '2 subagents', exact: true })
await subagents.waitFor({ timeout: 15_000 })
await subagents.hover()
const catalog = page.getByRole('tree', { name: 'Subagent sessions' })
+5 -16
View File
@@ -296,24 +296,13 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
})
it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => {
it.skipIf(MODE === 'record')('omits system prompt rows even when the Turn process is expanded', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
await expandTurnProcesses(page)
const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
const body = page.locator('[data-system-prompt-body]')
await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
expect(await body.count()).toBe(0)
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
const opaque = body.locator('[data-context-text]')
await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1)
expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.')
await disclosure.click()
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
await expect.poll(() => body.count()).toBe(0)
expect(sessionEvents.some(event => event.type === 'system/message')).toBe(true)
expect(await page.locator('[data-chat-flow-kind="system-prompt"]').count()).toBe(0)
expect(await page.getByRole('button', { name: 'System prompt', exact: true }).count()).toBe(0)
expect(await page.locator('[data-system-prompt-body]').count()).toBe(0)
})
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
+9 -12
View File
@@ -721,24 +721,21 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (bodyError !== undefined) throw bodyError
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
it.skipIf(MODE === 'record')('an Access-chip switch persists its command without adding a Chat row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset read-only`,
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
const access = page.getByRole('button', { name: 'Access mode, current: Read Only' })
await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true)
// Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
expect(await row.count()).toBe(0)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
const events = agent.session.snapshotEvents()
const run = events.findLast(event => event.type === 'command/run' && event.data.name === 'permission')
if (run?.type !== 'command/run') throw new Error('permission command was not persisted')
expect(events.find(event => event.type === 'command/done' && event.data.commandId === run.data.commandId)?.data)
.toMatchObject({ kind: 'success', text: 'preset read-only' })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
+14 -10
View File
@@ -548,17 +548,20 @@ describe('web e2e: settings modal and General preferences', () => {
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it('persists the completed-Turn transcript mode across reload', async () => {
it.each([
['detailed', '详细'], ['expanded', '完全展开'],
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '紧凑', exact: true }).click()
await page.getByRole('menuitem', { name: '标准', exact: true }).click()
await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 })
const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..')
await details.getByRole('button', { name: '简洁', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-chat:\n\s+transcriptView: normal/)
.toContain(`transcriptView: ${mode}`)
await page.keyboard.press('Escape')
const warningStart = tripwire.warnings.length
@@ -567,11 +570,12 @@ describe('web e2e: settings modal and General preferences', () => {
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '设置', exact: true }).click()
const reloaded = page.getByRole('dialog', { name: '设置' })
await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..')
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await reloaded.getByRole('button', { name: '标准', exact: true }).click()
await page.getByRole('menuitem', { name: '紧凑', exact: true }).click()
await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 })
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: '简洁', exact: true }).click()
await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-chat:\n\s+transcriptView: compact/)
await page.keyboard.press('Escape')
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
- button "Context injection runtime-context"
+12 -7
View File
@@ -232,7 +232,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => {
it.skipIf(MODE === 'record').each([
['detailed', 'Detailed'], ['expanded', 'Expanded'],
] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
const { settled } = await sendPrompt()
@@ -245,18 +247,21 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
await dialog.getByText('Work details', { exact: true }).locator('../..')
.getByRole('button', { name: 'Compact', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.keyboard.press('Escape')
await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0)
await tool.waitFor({ state: 'visible', timeout: 10_000 })
expect(await process.count()).toBe(1)
expect(await process.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-chat:\n\s+transcriptView: normal/)
.toContain(`transcriptView: ${mode}`)
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const restored = page.getByRole('dialog', { name: 'Settings' })
await restored.getByRole('button', { name: 'Normal', exact: true }).click()
await restored.getByText('Work details', { exact: true }).locator('../..')
.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
await page.keyboard.press('Escape')
await process.waitFor({ timeout: 10_000 })
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe
README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3
README.md: 3a0e74332a0ee2c5453a024f1ffe9dfbb9ae0163
README.zh.md: 89a385a61f08efa86c98d59fd4271f99c3b25210
+2
View File
@@ -67,6 +67,8 @@ The completed-turn action footer starts 20px below the preceding prose or extens
Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement.
Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded.
Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed.
-----
+2
View File
@@ -67,6 +67,8 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。
思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。
-----
+16 -7
View File
@@ -5,15 +5,24 @@ import z from '@deepseek-ai/schemastery'
/** Settings namespace owned by the Chat target. */
export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
/** Field carrying the completed-Turn transcript presentation mode. */
/** Field carrying the work-details presentation mode. */
export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
/** Transcript presentation modes accepted at settings boundaries. */
export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const
/** Work-details presentation modes a user can choose. */
export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const
/** Completed-Turn transcript presentation. */
/** Work-details presentation mode. */
export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
/**
* Saved value from the two-mode generation of this setting. Read as `detailed`;
* never offered as a choice and never written back.
*/
export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal'
/** Every value the durable field accepts: current modes plus the legacy saved value. */
const TRANSCRIPT_VIEW_SETTING_VALUES = [...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE] as const
/** Default preserves the compact process disclosure introduced by Chat. */
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
@@ -34,8 +43,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar'
/** Durable Chat section shared by the Host schema and browser scope. */
export interface ChatSettings {
/** Presentation mode for completed Turn process content. */
transcriptView: TranscriptViewMode
/** Work-details preference; the legacy value is accepted only from existing saved settings. */
transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
/** Default destination for Chat HTTP(S) links. */
@@ -46,5 +55,5 @@ export interface ChatSettings {
export const ChatSettingsSchema: z<ChatSettings> = z.object({
linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING),
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
})
+4 -2
View File
@@ -34,6 +34,7 @@ import { en, NS, zh } from './locale.ts'
import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
import { createChatStore } from './stores.ts'
import { TranscriptViewPolicy } from './transcript-view.ts'
import { derivePresentationPolicy } from './presentation-policy.ts'
import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts'
import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx'
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
@@ -111,9 +112,10 @@ export function apply(ctx: Context): void {
}, LinkOpeningRow))
})
const transcriptView = new TranscriptViewPolicy(chatSettings)
const presentation = derivePresentationPolicy(transcriptView.mode)
const performancePolicy = new PerformanceUsagePolicy(chatSettings)
const performanceUsage = performancePolicy.mode
registerChatNodeRenderers(ctx, performanceUsage)
registerChatNodeRenderers(ctx, performanceUsage, presentation)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
@@ -155,7 +157,7 @@ export function apply(ctx: Context): void {
const session = binding.session
const chat = chatSource(binding)
return {
hooks: { transcriptView: transcriptView.mode },
hooks: { presentation },
keyedHooks: {
chatNode: key => chat.getSnapshot().nodes.source(key),
chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
@@ -2,7 +2,7 @@ import { Fragment, memo, useMemo } from 'react'
import type { ReactNode } from 'react'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
import type { AssistantBlock } from '../contract/snapshot.ts'
import { markdownLabels } from '../markdown-labels.ts'
import { ReasoningRow } from './ReasoningRow.tsx'
@@ -29,9 +29,11 @@ export interface AssistantMarkdownProps {
interrupted?: boolean | undefined
/** Render consecutive image blocks through the attachment slot. */
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
/** Hide reasoning that belongs to the Turn-level process disclosure. */
/** Hide reasoning that belongs to the Turn-level process disclosure or a collapsed step group. */
reasoningHidden?: boolean | undefined
/** Reveal the owning Turn-level process disclosure. */
/** Live display policy for reasoning summaries. */
usePresentation: UsePresentation
/** Reveal the disclosure that hides this reasoning. */
revealProcess?: (() => void) | undefined
/** Resolved prose file mentions for this Assistant's closing turn. */
mentions?: MarkdownFileMentions | undefined
@@ -42,7 +44,7 @@ export interface AssistantMarkdownProps {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, renderMessageImages,
reasoningHidden = false, revealProcess, mentions, t,
reasoningHidden = false, usePresentation, revealProcess, mentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -83,7 +85,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
hidden={reasoningHidden}
reveal={revealProcess}
>
<ReasoningRow text={block.text} running={streaming && i === last} t={t} />
<ReasoningRow text={block.text} running={streaming && i === last} usePresentation={usePresentation} t={t} />
</ProcessReasoning>,
)
break
@@ -1,11 +1,14 @@
import { memo, useCallback, useMemo } from 'react'
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNodeViewProps, PresentationInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace<PresentationInjected>
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
export const AssistantNodeView = memo(function AssistantNodeView({
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, usePresentation, t,
}: AssistantNodeViewProps) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
@@ -33,6 +36,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({
interrupted={data.status === 'interrupted'}
renderMessageImages={renderMessageImages}
reasoningHidden={reasoningHidden}
usePresentation={usePresentation}
revealProcess={revealProcess}
mentions={mentions}
t={t}
@@ -1,7 +1,7 @@
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 { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { storedTurnProcessEntry } from '../stores.ts'
@@ -12,8 +12,7 @@ interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly nodeKey: string
readonly useChatNode: ChatViewSlotProps['useChatNode']
readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
readonly historyIncomplete: boolean
readonly compactTranscript: boolean
readonly usePresentation: UsePresentation
readonly useStore: ChatViewSlotProps['useStore']
readonly actions: ChatViewSlotProps['actions']
readonly renderSlot: ChatViewSlotProps['renderSlot']
@@ -34,9 +33,13 @@ function turnOf(node: ChatNode | undefined): number | undefined {
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn.turn : undefined
}
/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
/**
* Subscribe, apply Turn-process visibility, and dispatch one stable Context key.
* Policy reads select this seat's own conclusion, so a mode change re-renders
* only seats whose visibility actually changes.
*/
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
nodeKey, useChatNode, useChatNodeProcess, usePresentation,
cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
}: ChatNodeSeatProps) {
@@ -59,13 +62,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open)
}
}, [actions, processSpec])
const foldCompleted = usePresentation(policy => policy.foldCompletedTurns)
// Folding is decided per Turn: the Turn is closed and its start is loaded.
const processWindowReady = processSpec !== undefined
&& processPresentation !== undefined
&& compactTranscript
&& foldCompleted
&& processSpec.answerAnchorSeq !== null
&& processPresentation.turn === processSpec.turn
&& processPresentation.turnClosed
&& !historyIncomplete
&& processPresentation.turnStarted
const processMember = routedNode !== undefined
&& processWindowReady
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
@@ -113,7 +113,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
export function ChatView({
useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
useTranscriptView, useProjection, t,
usePresentation, useProjection, t,
}: ChatViewSlotProps) {
const order = useChat(s => s.order)
const nodeStore = useChat(s => s.nodes)
@@ -137,7 +137,6 @@ export function ChatView({
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const compactTranscript = useTranscriptView(mode => mode === 'compact')
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
const [fileOpenBusy, setFileOpenBusy] = useState(false)
// Close/retry must ignore a settlement that started before the latest
@@ -237,8 +236,7 @@ export function ChatView({
order={order}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
historyIncomplete={hasMore}
compactTranscript={compactTranscript}
usePresentation={usePresentation}
useStore={useStore}
actions={actions}
cwd={cwd}
@@ -3,32 +3,6 @@
flex-direction: column;
}
.row {
position: relative;
overflow: hidden;
}
.root[data-state='running'] .row::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-command-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-command-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
@@ -39,6 +13,7 @@
.title {
font-weight: 400;
transition: color 100ms ease;
}
.separator {
@@ -59,6 +34,12 @@
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
transition: color 100ms ease;
}
.row:hover .title,
.row:hover .summary:not([data-error]) {
color: var(--dsw-alias-label-primary);
}
.summary[data-error],
@@ -78,9 +59,3 @@
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
import a11yCss from './accessibility.module.css'
import css from './GenericCommandCard.module.css'
type CommandRowState = 'running' | 'ok' | 'error'
const COMMAND_ICON = <IconApiOutlineRegular size={14} />
/** Node state → row state semantic (running while unsettled; outcome kind after). */
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowState {
if (outcome === null) return 'running'
@@ -19,7 +21,12 @@ export interface GenericCommandCardProps extends CommandRowOwnerProps {
runningSummary?: string | undefined
}
export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
/**
* Render a command summary and its lazily mounted multiline output.
* @param props - command, locale, and optional running label.
* @returns the command disclosure.
*/
export const GenericCommandCard = memo(function GenericCommandCard({ node, t, runningSummary }: GenericCommandCardProps) {
const [expanded, setExpanded] = useState(false)
const text = node.outcome?.text
const summary = node.outcome === null
@@ -29,8 +36,21 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
// command name.
const title = node.name ?? t('command.title')
const state = stateOf(node.outcome)
const running = state === 'running'
const body = text !== undefined && text.includes('\n') ? text : null
const open = expanded && body !== null
const toggle = useCallback(() => { setExpanded(value => !value) }, [])
const collapsedContent = useMemo(() => (
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>
<TextShimmer active={running}>{summary}</TextShimmer>
</span>
</>
), [running, state, summary])
const content = useMemo(() => open
? <pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
: undefined, [body, open, state])
return (
<div className={css.root} data-variant="others" data-state={state}>
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
@@ -40,22 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconApiOutlineRegular size={14} />}
icon={COMMAND_ICON}
title={title}
running={running}
open={open}
expandable={body !== null}
expandOnRowClick
keepContentWhenOpen
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
</>
)}
onToggle={toggle}
collapsedContent={collapsedContent}
>
<pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
{content}
</DisclosureRow>
</div>
)
}
})
@@ -83,20 +83,20 @@
text-overflow: ellipsis;
}
.summary[data-follow-end] {
display: flex;
justify-content: flex-end;
.summary[data-streaming] {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
}
.summary[data-follow-end] .summaryText {
flex: 0 0 auto;
width: max-content;
min-width: 100%;
.summary[data-streaming] .summaryText {
overflow: visible;
text-align: start;
text-overflow: clip;
}
.root:not([data-preview]) .separator,
.root:not([data-preview]) .summary {
display: none;
}
.thinkBody {
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
@@ -1,35 +1,74 @@
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
import { useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, UsePresentation } from '../contract/slots.ts'
import { markdownLabels } from '../markdown-labels.ts'
import a11yCss from './accessibility.module.css'
import css from './ReasoningRow.module.css'
const THINK_ICON = <IconThinkOutlineRegular size={14} />
function firstLine(text: string): string {
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
}
function latestLine(text: string): string {
const visible = text.trimEnd()
const newline = visible.lastIndexOf('\n')
return newline === -1 ? visible : visible.slice(newline + 1)
function latestCompletedParagraphFirstLine(text: string): string {
let summary = ''
let paragraphStart = 0
const separator = /\r?\n[\t ]*\r?\n/g
while (true) {
const nextParagraph = separator.exec(text)
const paragraphEnd = nextParagraph === null ? text.length
: nextParagraph.index + nextParagraph[0].indexOf('\n')
const newline = text.indexOf('\n', paragraphStart)
if (newline !== -1 && newline <= paragraphEnd) {
const candidate = text.slice(paragraphStart, newline).trim()
if (candidate !== '') summary = candidate
}
if (nextParagraph === null) return summary
paragraphStart = nextParagraph.index + nextParagraph[0].length
}
}
/**
* Render one assistant reasoning block collapsed until the reader opens it. The
* collapsed summary omits double-asterisk markers; expanded content renders
* the complete Markdown with secondary typography.
* the complete Markdown with secondary typography. A streaming preview advances
* when a paragraph's first line completes. Mode changes toggle CSS display without unmounting
* collapsed summaries.
* @param props.text - complete or streaming reasoning text.
* @param props.running - whether this block is the streaming tail.
* @param props.usePresentation - live display-policy selector for this reasoning row.
* @param props.t - conversation locale seat for status and Markdown actions.
* @returns the reasoning disclosure.
*/
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
export const ReasoningRow = memo(function ReasoningRow({ text, running, usePresentation, t }: {
text: string
running: boolean
usePresentation: UsePresentation
t: ChatViewSlotProps['t']
}) {
const [expanded, setExpanded] = useState(false)
const labels = useMemo(() => markdownLabels(t), [t])
const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
const toggle = useCallback(() => { setExpanded(value => !value) }, [])
const summaryText = running ? latestCompletedParagraphFirstLine(text) : firstLine(text)
const summary = useMemo(() => summaryText.replaceAll('**', ''), [summaryText])
const preview = usePresentation(policy => !expanded && summary !== ''
&& (running || policy.settledReasoningPreview))
const collapsedContent = useMemo(() => (
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-streaming={running || undefined}>
<span className={css.summaryText}>{summary}</span>
</span>
</>
), [running, summary])
const content = useMemo(() => expanded ? (
<div className={css.thinkBody}>
<MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
</div>
) : undefined, [expanded, labels, running, text])
return (
<div
@@ -37,6 +76,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
data-variant="think"
data-state={running ? 'running' : 'ok'}
data-expanded={expanded || undefined}
data-preview={preview || undefined}
>
{running && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
<DisclosureRow
@@ -44,25 +84,16 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconThinkOutlineRegular size={14} />}
icon={THINK_ICON}
title={t('message.think')}
open={expanded}
expandable
expandOnRowClick
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-follow-end={running || undefined}>
<span className={css.summaryText}>{summary}</span>
</span>
</>
)}
onToggle={toggle}
collapsedContent={collapsedContent}
>
<div className={css.thinkBody}>
<MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
</div>
{content}
</DisclosureRow>
</div>
)
}
})
@@ -1,6 +1,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PerformanceUsageMode } from '../../chat-settings.ts'
import type { ChatPresentationPolicy } from '../presentation-policy.ts'
import { NS } from '../locale.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
@@ -14,10 +15,17 @@ import { TurnTailNodeView } from './TurnTailNodeView.tsx'
/**
* Register this package's business renderers behind the keyed Chat Node seat.
* Renderers whose output depends on the work-details mode receive the policy
* through their own registration; the seat and the other renderers do not.
* @param ctx - owning UI Conversation context.
* @param performanceUsage - live statistics detail preference.
* @param presentation - live presentation policy.
*/
export function registerChatNodeRenderers(ctx: Context, performanceUsage: ObservableSnapshot<PerformanceUsageMode>): void {
export function registerChatNodeRenderers(
ctx: Context,
performanceUsage: ObservableSnapshot<PerformanceUsageMode>,
presentation: ObservableSnapshot<ChatPresentationPolicy>,
): void {
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
@@ -26,8 +34,12 @@ export function registerChatNodeRenderers(ctx: Context, performanceUsage: Observ
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'assistant-step',
locale: NS,
inject: () => ({ hooks: { presentation } }),
}, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'command',
@@ -0,0 +1,13 @@
/** Chat-only row visibility; durable events and trajectory inspection remain intact. */
import type { ChatNode } from './chat-nodes.ts'
/**
* Exclude system prompts and permission commands from visible Chat rows.
* @param node - projected Chat node.
* @returns whether the node contributes a visible Chat row.
*/
export function isVisibleChatNode(node: ChatNode): boolean {
return node.visibility === 'visible'
&& node.kind !== 'system-prompt'
&& !(node.kind === 'command' && node.data.name === 'permission')
}
@@ -9,17 +9,18 @@ import type {
InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot } 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 { ChatPresentationPolicy } from '../presentation-policy.ts'
import type { ToolCallId } from './store.ts'
import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type {
ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation,
} from './snapshot.ts'
import type { TurnProcessSpec } from './turn-process.ts'
import type { PerformanceUsageMode, TranscriptViewMode } from '../../chat-settings.ts'
import type { PerformanceUsageMode } from '../../chat-settings.ts'
/** Selector hook over the current Conversation binding's Chat target. */
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
@@ -30,6 +31,13 @@ export type UseChatNode = KeyedSnapshotSelectorHook<ChatConversationViewNode | u
/** Per-key selector hook over one Chat Node's Turn-process presentation. */
export type UseChatNodeProcess = KeyedSnapshotSelectorHook<ChatTurnProcessPresentation | undefined>
/**
* Selector hook over the live presentation policy. Callers select one field or
* a derived conclusion, never the whole policy, so a mode change re-renders
* only components whose selected value changed.
*/
export type UsePresentation = SnapshotSelectorHook<ChatPresentationPolicy>
/** Where in a file an open should land. */
export interface OpenFileOptions {
/** 1-based line to reveal; absent = the file's beginning. */
@@ -105,6 +113,14 @@ export interface TurnProcessOwnerProps {
setOpen(open: boolean): void
}
/** Shared presentation policy source for renderers that depend on the work-details mode. */
export interface PresentationInjected {
hooks: {
/** Live presentation policy derived from the accepted work-details mode. */
presentation: ObservableSnapshot<ChatPresentationPolicy>
}
}
/** Full props of one keyed Chat renderer. */
export type ChatNodeViewProps<Kind extends ChatNodeKind = ChatNodeKind> =
PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'chat'>
@@ -139,8 +155,8 @@ export interface PerformanceUsageInjected {
/** Business callbacks injected into the Chat view. */
export interface ChatViewInjected {
hooks: {
/** Persisted completed-Turn transcript presentation. */
transcriptView: SnapshotStore<TranscriptViewMode>
/** Live presentation policy derived from the accepted work-details mode. */
presentation: ObservableSnapshot<ChatPresentationPolicy>
}
keyedHooks: {
/** Resolve the stable source for one Chat Node key. */
@@ -74,6 +74,8 @@ export interface ChatLocationNodeIndex {
export interface ChatTurnProcessPresentation {
readonly turn: number
readonly spec: TurnProcessSpec
/** Whether the loaded window contains this Turn's `turn/start`; folding is decided per Turn on this fact. */
readonly turnStarted: boolean
readonly turnClosed: boolean
readonly hasExternalProcess: boolean
readonly compactAnswer: boolean
@@ -6,6 +6,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type {
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot,
ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem,
@@ -400,7 +401,7 @@ function presentationPosition(
export function orderedVisibleChatNodes(
nodes: readonly ChatConversationViewNode[],
): ChatConversationViewNode[] {
const visible = nodes.filter(node => node.visibility === 'visible')
const visible = nodes.filter(node => isVisibleChatNode(node as ChatNode))
const presentations = turnProcessPresentations(visible)
return visible.sort((left, right) => {
const leftPosition = presentationPosition(left, presentations)
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts'
@@ -82,7 +83,7 @@ export function turnNavigationItem(
): TurnNavigationItem | undefined {
const loaded = locations.getTurn(turn)
.map(key => nodes.get(key))
.filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible')
.filter((node): node is ChatNode => node !== undefined && isVisibleChatNode(node as ChatNode))
const user = loaded.find(node => node.kind === 'user')
const anchor = user ?? loaded[0]
if (anchor === undefined) return undefined
@@ -1,3 +1,4 @@
import { isVisibleChatNode } from '../contract/chat-visibility.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation,
@@ -16,6 +17,7 @@ function samePresentation(
return left === right || (left !== undefined && right !== undefined
&& left.spec === right.spec
&& left.turn === right.turn
&& left.turnStarted === right.turnStarted
&& left.turnClosed === right.turnClosed
&& left.hasExternalProcess === right.hasExternalProcess
&& left.compactAnswer === right.compactAnswer)
@@ -48,7 +50,7 @@ function derivePresentation(
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 === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue
if ((node.kind === 'user' || node.kind === 'steering')
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
&& (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
@@ -64,6 +66,7 @@ function derivePresentation(
return {
turn,
spec,
turnStarted: location.turn.start !== undefined,
turnClosed: location.turn.status === 'closed',
hasExternalProcess,
compactAnswer,
+4 -2
View File
@@ -30,11 +30,13 @@ export type {
export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
export type { TranscriptViewMode } from '../chat-settings.ts'
export type { ChatPresentationPolicy } from './presentation-policy.ts'
export { derivePresentationPolicy, presentationPolicyFor } from './presentation-policy.ts'
export type {
AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions,
TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected,
TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UsePresentation,
} from './contract/slots.ts'
export type {
TurnProcessSpec,
+9 -7
View File
@@ -35,10 +35,11 @@ export const zh = {
'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置',
'settings.links.sidebar': '内置浏览器',
'settings.links.newTab': '浏览器新标签页',
'settings.transcript.title': '对话显示',
'settings.transcript.description': '控制已完成轮次的过程内容',
'settings.transcript.normal': '标准',
'settings.transcript.compact': '紧凑',
'settings.transcript.title': '工作过程展示',
'settings.transcript.description': '控制轮次和步骤的默认展开方式',
'settings.transcript.compact': '简洁',
'settings.transcript.detailed': '详细',
'settings.transcript.expanded': '完全展开',
'fileOpen.title': '无法打开文件',
'fileOpen.unknown': '无法打开此文件',
'message.extraBlock': '附加内容块',
@@ -149,10 +150,11 @@ export const en = {
'settings.links.description': 'Choose where HTTP(S) links in chat open',
'settings.links.sidebar': 'Built-in browser',
'settings.links.newTab': 'New browser tab',
'settings.transcript.title': 'Conversation display',
'settings.transcript.description': 'Controls process content in completed turns',
'settings.transcript.normal': 'Normal',
'settings.transcript.title': 'Work details',
'settings.transcript.description': 'Controls how turns and steps expand by default',
'settings.transcript.compact': 'Compact',
'settings.transcript.detailed': 'Detailed',
'settings.transcript.expanded': 'Expanded',
'fileOpen.title': 'Couldn’t open file',
'fileOpen.unknown': 'Couldn’t open this file',
'message.extraBlock': 'Extra content block',
@@ -0,0 +1,70 @@
/**
* Runtime vocabulary derived from the persisted work-details mode. Renderers
* and seats select single fields of this policy; none of them compares the
* mode enum, so adding a mode changes only the table below.
*/
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { TranscriptViewMode } from '../chat-settings.ts'
/** Presentation capabilities that one work-details mode enables. */
export interface ChatPresentationPolicy {
/** Mode this policy was derived from; for diagnostics, never for branching in renderers. */
readonly mode: TranscriptViewMode
/** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */
readonly foldCompletedTurns: boolean
/**
* How consecutive process rows between Assistant replies present: `collapsed`
* groups them behind a step-group row that starts closed; `none` renders
* every row directly under the whole-Turn control with no group rows.
*/
readonly stepGrouping: 'collapsed' | 'none'
/** Whether a settled reasoning row previews its first line beside the Think title. */
readonly settledReasoningPreview: boolean
}
const POLICIES: Readonly<Record<TranscriptViewMode, ChatPresentationPolicy>> = {
compact: {
mode: 'compact',
foldCompletedTurns: true,
stepGrouping: 'collapsed',
settledReasoningPreview: false,
},
detailed: {
mode: 'detailed',
foldCompletedTurns: true,
stepGrouping: 'collapsed',
settledReasoningPreview: true,
},
expanded: {
mode: 'expanded',
foldCompletedTurns: true,
stepGrouping: 'none',
settledReasoningPreview: true,
},
}
/**
* Resolve the policy constant for one mode. The same mode always yields the
* same object, so selectors over a policy see stable identities.
* @param mode - persisted work-details mode.
* @returns the mode's presentation policy.
*/
export function presentationPolicyFor(mode: TranscriptViewMode): ChatPresentationPolicy {
return POLICIES[mode]
}
/**
* Derive a policy observable from the mode observable without a subscription of
* its own: reads are a table lookup and change notifications are the mode's.
* @param mode - live work-details mode.
* @returns observable policy that changes exactly when the mode changes.
*/
export function derivePresentationPolicy(
mode: ObservableSnapshot<TranscriptViewMode>,
): ObservableSnapshot<ChatPresentationPolicy> {
return {
getSnapshot: () => POLICIES[mode.getSnapshot()],
subscribe: listener => mode.subscribe(listener),
}
}
@@ -1,18 +1,18 @@
/** General Settings row for completed-Turn transcript presentation. */
/** General Settings row for work-details presentation. */
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TranscriptViewMode } from '../../chat-settings.ts'
import { TRANSCRIPT_VIEW_MODES, type TranscriptViewMode } from '../../chat-settings.ts'
import type { ChatKey } from '../locale.ts'
import { PreferenceRow } from './PreferenceRow.tsx'
/** Registration-side transcript preference face. */
/** Registration-side work-details preference face. */
export interface TranscriptViewRowInjected {
hooks: {
/** Persisted transcript preference bound as useTranscriptView. */
/** Persisted work-details preference bound as useTranscriptView. */
transcriptView: SnapshotStore<TranscriptViewMode>
}
/** Change the completed-Turn transcript presentation. */
/** Change the work-details presentation. */
setTranscriptView: (mode: TranscriptViewMode) => void
}
@@ -22,28 +22,26 @@ export type TranscriptViewRowProps =
& PropsLocale<'chat'>
& InjectFace<TranscriptViewRowInjected>
const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [
{ id: 'normal', label: 'settings.transcript.normal' },
{ id: 'compact', label: 'settings.transcript.compact' },
]
const LABELS = {
compact: 'settings.transcript.compact',
detailed: 'settings.transcript.detailed',
expanded: 'settings.transcript.expanded',
} as const satisfies Record<TranscriptViewMode, ChatKey>
/**
* Render the completed-Turn transcript mode selector.
* Render the work-details mode selector.
* @param props - composed Settings slot props.
* @returns the preference row.
*/
export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) {
const mode = useTranscriptView(value => value)
const selectedLabel = mode === 'normal'
? 'settings.transcript.normal'
: 'settings.transcript.compact'
return (
<PreferenceRow
title={t('settings.transcript.title')}
description={t('settings.transcript.description')}
value={mode}
selectedLabel={t(selectedLabel)}
options={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
selectedLabel={t(LABELS[mode])}
options={TRANSCRIPT_VIEW_MODES.map(id => ({ id, label: t(LABELS[id]) }))}
onSelect={(value) => { setTranscriptView(value as TranscriptViewMode) }}
/>
)
@@ -1,13 +1,13 @@
/** Host-backed completed-Turn transcript presentation policy. */
/** Host-backed work-details presentation policy. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
import {
DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
type ChatSettings, type TranscriptViewMode,
} from '../chat-settings.ts'
/** Live transcript preference consumed by Chat and its Settings row. */
/** Live work-details preference consumed by Chat and its Settings row. */
export class TranscriptViewPolicy {
/** Reactive current mode; defaults to Compact before Host settings arrive. */
readonly mode: SnapshotStore<TranscriptViewMode> = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
@@ -22,7 +22,7 @@ export class TranscriptViewPolicy {
/**
* Publish and persist one explicit user choice.
* @param mode - Normal or Compact transcript presentation.
* @param mode - Compact, Detailed, or Expanded work details.
*/
setMode(mode: TranscriptViewMode): void {
if (this.mode.getSnapshot() === mode) return
@@ -33,7 +33,8 @@ export class TranscriptViewPolicy {
/** Adopt the latest accepted Host section without writing it back. */
private adopt(): void {
const section = this.host.getSnapshot().value
if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return
this.mode.set(section.transcriptView)
if (section === undefined) return
const mode = section.transcriptView === LEGACY_TRANSCRIPT_VIEW_MODE ? 'detailed' : section.transcriptView
if (this.mode.getSnapshot() !== mode) this.mode.set(mode)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import type {} from '@deepseek-ai/dsh-settings'
import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts'
export {
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, LEGACY_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode,
} from './chat-settings.ts'
@@ -2,6 +2,7 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts'
import type { AssistantBlock } from '../src/client/contract/snapshot.ts'
@@ -54,6 +55,7 @@ describe('AssistantMarkdown local-path images', () => {
it('renders a local image path in closing prose through the same-origin API', () => {
const { container } = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
blocks={[textBlock('See ![diagram](/tmp/graph.png) for the layout.')]}
streaming={false}
renderMessageImages={renderMessageImages}
@@ -70,6 +72,7 @@ describe('AssistantMarkdown local-path images', () => {
it('keeps non-absolute destinations inert', () => {
const { container } = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
blocks={[textBlock('See ![diagram](relative.png).')]}
streaming={false}
renderMessageImages={renderMessageImages}
@@ -109,9 +109,9 @@ describe('Chat apply wiring', () => {
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
face.setTranscriptView('normal')
expect(face.hooks.transcriptView.getSnapshot()).toBe('normal')
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal')
face.setTranscriptView('detailed')
expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
b.chatSettings.publish({
status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true,
@@ -16,6 +16,7 @@ import {
UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -1044,6 +1045,7 @@ describe('small branch tails', () => {
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'one-liner' }]}
streaming={false}
@@ -28,6 +28,7 @@ import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversa
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.ts'
import { derivePresentationPolicy } from '../src/client/presentation-policy.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'
@@ -309,7 +310,7 @@ function makeHarness(
case 'context':
return <ContextMessageNodeView {...nodeProps<'context'>()} />
case 'assistant-step':
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
return <AssistantNodeView {...nodeProps<'assistant-step'>()} usePresentation={props.usePresentation} />
case 'command':
return (
<CommandNodeView
@@ -402,7 +403,7 @@ function makeHarness(
},
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
useTranscriptView: bindSnapshotSelector(transcriptView),
usePresentation: bindSnapshotSelector(derivePresentationPolicy(transcriptView)),
renderSlot,
SessionProvider: SessionProviderStub,
inspectCall: (callId: string) => { openView('trajectory', callId) },
@@ -1471,6 +1472,7 @@ describe('ChatView', () => {
assistant(5, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 6]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 6_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
@@ -1484,18 +1486,16 @@ describe('ChatView', () => {
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
})
it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
it('omits the System prompt through Turn completion and process expansion', () => {
const builder = new ChatSnapshotBuilder()
const initial = withSystemPrompt(chatSnapshotFixture({
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
turnTimings: new Map([[1, { startTime: 0 }]]),
}), 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"]')!
expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
expect(renderedFlowKinds(view.container)).toEqual(['user', 'context'])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
act(() => {
h.set({
@@ -1505,15 +1505,15 @@ describe('ChatView', () => {
context(3, 'runtime policy', 1),
reasoningAssistant(4, 'inspect', 1, 1),
],
turnTimings: new Map([[1, { startTime: 0 }]]),
}), builder),
running: true,
})
})
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
'user', 'turn-process', 'context', 'assistant-step',
])
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
act(() => {
h.set({
@@ -1525,6 +1525,7 @@ describe('ChatView', () => {
assistant(6, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 7]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 7_000 }]]),
}), builder),
running: false,
})
@@ -1532,19 +1533,18 @@ describe('ChatView', () => {
const toggle = turnProcessControl(view.container)!
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
fireEvent.click(toggle)
expect(renderedFlowKinds(view.container)).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(promptRow.getAttribute('hidden')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
})
@@ -1552,6 +1552,7 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已思考' })
@@ -1606,6 +1607,7 @@ describe('ChatView', () => {
nodes: [user(1, 'question'), process],
partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] },
running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
@@ -1624,11 +1626,12 @@ describe('ChatView', () => {
expect(processRow.getAttribute('hidden')).toBe('until-found')
})
it('switches completed Turns between the persisted Normal and Compact modes', () => {
it('preserves whole-Turn folding and manual expansion across work-details modes', () => {
const process = assistant(2, 'inspect', 1, 1)
const h = makeHarness({
nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)],
turnEnds: new Map([[1, 5]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
@@ -1636,13 +1639,20 @@ describe('ChatView', () => {
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
act(() => { h.setTranscriptView('normal') })
expect(turnProcessControl(view.container)).toBeNull()
expect(processRow.getAttribute('hidden')).toBeNull()
act(() => { h.setTranscriptView('compact') })
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
const toggle = turnProcessControl(view.container)!
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
}
fireEvent.click(toggle)
for (const mode of ['detailed', 'expanded', 'compact'] as const) {
act(() => { h.setTranscriptView(mode) })
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(processRow.getAttribute('hidden')).toBeNull()
}
})
it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
@@ -1653,7 +1663,11 @@ describe('ChatView', () => {
{ kind: 'text' as const, text: 'final answer' },
],
}
const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
const h = makeHarness({
nodes: [user(1, 'question'), final],
turnEnds: new Map([[1, 4]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已思考' })
const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
@@ -1666,7 +1680,10 @@ describe('ChatView', () => {
it('folds a completed Turn even while the reader is away from the tail', () => {
const first = assistant(2, 'first answer', 1, 1)
const h = makeHarness({ nodes: [user(1, 'question'), first], running: true })
const h = makeHarness({
nodes: [user(1, 'question'), first], running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
@@ -1688,6 +1705,7 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -1715,6 +1733,7 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
running: true,
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const contextToggle = view.getByRole('button', { name: '上下文注入' })
@@ -1742,7 +1761,7 @@ describe('ChatView', () => {
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
})
it('keeps a foldable closed Turn fully visible while history is partial', () => {
it('withholds folding until the Turn start arrives even when older history remains', () => {
const h = makeHarness({
nodes: [
user(1, 'question'),
@@ -1761,6 +1780,13 @@ describe('ChatView', () => {
expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false)
act(() => { h.set({ hasMore: false }) })
expect(turnProcessControl(view.container)).toBeNull()
expect(contextRow?.getAttribute('hidden')).toBeNull()
act(() => { h.set({
hasMore: true,
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
}) })
const toggle = turnProcessControl(view.container)!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
@@ -1784,6 +1810,7 @@ describe('ChatView', () => {
assistant(10, 'visible answer', 2),
],
turnEnds: new Map([[1, 5]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
hasMore: false,
})
})
@@ -1807,6 +1834,7 @@ describe('ChatView', () => {
assistant(4, 'final answer', 1, 2),
],
turnEnds: new Map([[1, 5]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
})
const process = source.nodes.values()
.find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process')
@@ -1862,6 +1890,7 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
turnEnds: new Map([[1, 5]]),
turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]),
})
const view = render(<h.ChatView {...h.props} />, { container: host })
fireEvent.click(turnProcessControl(view.container)!)
@@ -166,6 +166,12 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und
return value.nodes.values().find(candidate => candidate.kind === kind)
}
function promptNodes(value: ChatSnapshot): ChatConversationViewNode[] {
const prompts = value.nodes.values().filter(candidate => candidate.kind === 'system-prompt')
for (const prompt of prompts) expect(value.order).not.toContain(prompt.key)
return prompts.sort((left, right) => left.anchorSeq - right.anchorSeq)
}
function textMessage(id: string, text: string) {
return {
id,
@@ -319,6 +325,42 @@ describe('built-in conversation node Definitions', () => {
expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true)
})
it.each(['replay', 'live', 'prepend'] as const)('hides permission commands but retains their data and ordinary Context (%s)', (mode) => {
const entries = [
at(1, 'command/run', { commandId: 'permission-1', name: 'permission', source: { kind: 'user' } }),
at(2, 'command/done', { commandId: 'permission-1', kind: 'success', text: 'Granted' }),
at(3, 'command/run', { commandId: 'plan-1', name: 'plan', source: { kind: 'user' } }),
at(4, 'command/done', { commandId: 'plan-1', kind: 'success', text: 'Plan mode' }),
at(5, 'turn/start', { turn: 1 }),
at(6, 'step/start', { turn: 1, step: 1 }),
at(7, 'user/message', {
...textMessage('context-1', 'workspace context'),
source: { kind: 'context' },
}, { surfaceOp: 'append' }),
]
const value = assembler(mode === 'replay' ? entries : [])
if (mode === 'live') {
for (const entry of entries) {
value.append(entry)
value.flush()
}
} else if (mode === 'prepend') {
value.replaceWindow(entries.slice(1), true)
value.flush()
expect(node(snapshot(value), 'command')?.data).toMatchObject({ name: null })
value.prepend(entries.slice(0, 1), false)
value.flush()
}
const current = snapshot(value)
const visible = current.order.map(key => current.nodes.get(key))
expect(visible.map(candidate => candidate?.kind)).toEqual(['command', 'context'])
expect(visible[0]?.data).toMatchObject({ name: 'plan' })
expect(current.nodes.values().filter(candidate => candidate.kind === 'command')).toHaveLength(2)
expect(node(current, 'command')?.data).toMatchObject({
name: 'permission', outcome: { kind: 'success', text: 'Granted' },
})
})
it('projects one reversible process window before the finalized answer', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -1543,7 +1585,7 @@ describe('built-in conversation node Definitions', () => {
expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
})
it('shows a complete appended prompt at the start of a headerless window', () => {
it('retains a complete appended prompt at the start of a headerless window', () => {
const value = assembler([
systemUpdateAt(10, '# Known prompt', 2, 1),
at(11, 'user/message', textMessage('window-user', 'continue'), { surfaceOp: 'append' }),
@@ -1558,8 +1600,8 @@ describe('built-in conversation node Definitions', () => {
], false)
value.flush()
const restored = snapshot(value)
expect(restored.order.map(key => restored.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.data)).toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }])
expect(promptNodes(restored).map(candidate => candidate.data))
.toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }])
})
it('withholds windowed replacement prompts until prepend resolves their positions', () => {
@@ -1595,10 +1637,7 @@ describe('built-in conversation node Definitions', () => {
]
const promptTexts = (value: ConversationNodeAssembler) => {
const restored = snapshot(value)
return restored.order.flatMap((key) => {
const candidate = restored.nodes.get(key)
return candidate?.kind === 'system-prompt' ? [candidate.data] : []
})
return promptNodes(restored).map(candidate => candidate.data)
}
windowed.prepend(older, false)
windowed.flush()
@@ -1608,7 +1647,7 @@ describe('built-in conversation node Definitions', () => {
expect(promptTexts(nodeless)).toEqual([{ text: '# Original prompt' }, { text: '# Original prompt' }])
})
it('shows the system node text as the request prompt card before the request messages', () => {
it('retains the request prompt without adding it to visible Chat rows', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -1626,7 +1665,6 @@ describe('built-in conversation node Definitions', () => {
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
'system-prompt',
'user',
'context',
])
@@ -1675,8 +1713,7 @@ describe('built-in conversation node Definitions', () => {
}
}
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.data)).toEqual([
expect(promptNodes(current).map(candidate => candidate.data)).toEqual([
{ text: 'A' }, { text: 'B', update: true }, { text: 'A' },
])
})
@@ -1704,15 +1741,15 @@ describe('built-in conversation node Definitions', () => {
])
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['user'])
value.append(systemAt(5, '# Replaced', 3))
value.flush()
const replaced = snapshot(value)
expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['user'])
})
it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => {
it('retains an in-history prompt update without duplicating it at the same-step header', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -1731,19 +1768,14 @@ describe('built-in conversation node Definitions', () => {
])
const cards = () => {
const current = snapshot(value)
return current.order.flatMap((key) => {
const candidate = current.nodes.get(key)
return candidate?.kind === 'system-prompt' ? [[candidate.anchorSeq, candidate.data]] : []
})
return promptNodes(current).map(candidate => [candidate.anchorSeq, candidate.data])
}
// The update is the model-visible change at its position; node 0 keeps its card.
expect(cards()).toEqual([
[1, { text: '# System' }],
[10, { text: '# Updated', update: true }],
])
// A series header in the same step shows nothing more: the update card already carries the text.
value.append(at(12, 'request/header', {
reason: 'series',
startsSeries: true,
@@ -1752,7 +1784,6 @@ describe('built-in conversation node Definitions', () => {
value.flush()
expect(cards()).toHaveLength(2)
// A later series header presents the effective prompt again, as any series start does.
value.append(at(13, 'step/end', { turn: 2, step: 1 }))
value.append(at(14, 'step/start', { turn: 2, step: 2 }))
value.append(at(15, 'request/header', {
@@ -1784,10 +1815,11 @@ describe('built-in conversation node Definitions', () => {
])
const current = snapshot(value)
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['user'])
expect(promptNodes(current).map(candidate => candidate.data)).toEqual([{ text: '# System' }])
})
it('keeps the initial system prompt before the opening User as Turn process state changes', () => {
it('keeps the system prompt out of Chat order as Turn process state changes', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -1808,14 +1840,14 @@ describe('built-in conversation node Definitions', () => {
}
const promptKey = node(snapshot(value), 'system-prompt')?.key
expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
expect(kinds()).toEqual(['user', 'context'])
value.append(at(7, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}))
value.flush()
expect(kinds()).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
'user', 'turn-process', 'context', 'assistant-step',
])
value.append(at(8, 'step/end', { turn: 1, step: 1 }))
@@ -1828,7 +1860,7 @@ describe('built-in conversation node Definitions', () => {
value.flush()
expect(kinds()).toEqual([
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
])
expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey)
})
@@ -1855,7 +1887,8 @@ describe('built-in conversation node Definitions', () => {
const candidate = current.nodes.get(key)
return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
})
expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
expect(ordered.map(candidate => candidate.kind)).toEqual(['user', 'user'])
expect(promptNodes(current).map(candidate => candidate.data)).toEqual([{ text: '# System' }])
})
it('places a withheld replacement prompt after prepend supplies its original node', () => {
@@ -1892,10 +1925,7 @@ describe('built-in conversation node Definitions', () => {
windowed.flush()
const restored = snapshot(windowed)
const prompts = restored.order.flatMap((key) => {
const candidate = restored.nodes.get(key)
return candidate?.kind === 'system-prompt' ? [candidate] : []
})
const prompts = promptNodes(restored)
expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 10])
expect(prompts.at(-1)?.data).toEqual({ text: windowedSystem })
expect(restored.nodes.get(user.key)).toBeDefined()
@@ -1936,11 +1966,8 @@ describe('built-in conversation node Definitions', () => {
const candidate = current.nodes.get(key)
return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
})
expect(ordered.map(candidate => candidate?.kind)).toEqual([
'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user',
])
expect(ordered.filter(candidate => candidate?.kind === 'system-prompt')
.map(candidate => candidate?.anchorSeq)).toEqual([1, 7, 10])
expect(ordered.map(candidate => candidate.kind)).toEqual(['user', 'user'])
expect(promptNodes(current).map(candidate => candidate.anchorSeq)).toEqual([1, 7, 10])
})
it('associates each direct message with its immediately following session recall', () => {
@@ -5,6 +5,7 @@ import { cleanup, 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 { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { zh } from '../src/client/locale.ts'
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
@@ -16,6 +17,7 @@ describe('tails', () => {
it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[
{ kind: 'reasoning', text: 'thinking hard\nsecond line' },
@@ -31,6 +33,7 @@ describe('tails', () => {
expect(view.getByText(/未知内容块/)).toBeTruthy()
const stopped = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'text', text: 'partial words' }]}
streaming={false}
@@ -46,6 +49,7 @@ describe('tails', () => {
// groups is layout noise (no text, no pulse, no interrupted marker).
const empty = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]}
streaming={false}
@@ -54,7 +58,9 @@ describe('tails', () => {
)
expect(empty.container.firstChild).toBeNull()
const blank = render(
<AssistantMarkdown t={t} blocks={[]} streaming={false} renderMessageImages={renderMessageImages} />,
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t} blocks={[]} streaming={false} renderMessageImages={renderMessageImages} />,
)
expect(blank.container.firstChild).toBeNull()
})
@@ -5,6 +5,7 @@ import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -22,6 +23,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]}
streaming
@@ -60,6 +62,7 @@ describe('render branch tails', () => {
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'still thinking' }]}
streaming
@@ -7,6 +7,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { zh } from '../src/client/locale.ts'
afterEach(cleanup)
@@ -44,6 +45,7 @@ describe('assistant image slot handoff', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'image', attachment }]}
streaming={false}
@@ -59,6 +61,7 @@ describe('assistant image slot handoff', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[
{ kind: 'image', attachment },
@@ -80,6 +83,7 @@ describe('assistant image slot handoff', () => {
const calls: MessageImagesRenderOwner[] = []
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[
{ kind: 'text', text: 'before' },
@@ -0,0 +1,10 @@
/** Fixed display policy for isolated Assistant Markdown fixtures. */
import type { UsePresentation } from '../src/client/contract/slots.ts'
import { presentationPolicyFor } from '../src/client/presentation-policy.ts'
/**
* Select from a fixed policy that shows settled reasoning previews.
* @param selector - component's policy selection.
* @returns the selected Detailed policy value.
*/
export const useDetailedPresentation: UsePresentation = selector => selector(presentationPolicyFor('detailed'))
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import type { TranscriptViewMode } from '../src/chat-settings.ts'
import { derivePresentationPolicy, presentationPolicyFor } from '../src/client/presentation-policy.ts'
afterEach(cleanup)
describe('Chat presentation policy', () => {
it.each([
['compact', 'collapsed', false],
['detailed', 'collapsed', true],
['expanded', 'none', true],
] as const)('maps %s to stable presentation capabilities', (mode, stepGrouping, settledReasoningPreview) => {
const policy = presentationPolicyFor(mode)
expect(policy).toEqual({ mode, foldCompletedTurns: true, stepGrouping, settledReasoningPreview })
expect(presentationPolicyFor(mode)).toBe(policy)
})
it('forwards mode notifications and removes the subscription on disposal', () => {
const mode = createSnapshotStore<TranscriptViewMode>('compact')
const policy = derivePresentationPolicy(mode)
const listener = vi.fn()
const dispose = policy.subscribe(listener)
expect(policy.getSnapshot()).toBe(presentationPolicyFor('compact'))
mode.set('detailed')
expect(listener).toHaveBeenCalledTimes(1)
expect(policy.getSnapshot()).toBe(presentationPolicyFor('detailed'))
dispose()
mode.set('expanded')
expect(listener).toHaveBeenCalledTimes(1)
})
it('renders only consumers whose selected field changes', () => {
const mode = createSnapshotStore<TranscriptViewMode>('compact')
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
const foldRender = vi.fn()
const previewRender = vi.fn()
function Fold() {
const fold = usePresentation(policy => policy.foldCompletedTurns)
foldRender(fold)
return <span>{String(fold)}</span>
}
function Preview() {
const preview = usePresentation(policy => policy.settledReasoningPreview)
previewRender(preview)
return <span>{String(preview)}</span>
}
render(<><Fold /><Preview /></>)
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(1)
act(() => { mode.set('detailed') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('expanded') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(2)
act(() => { mode.set('compact') })
expect(foldRender).toHaveBeenCalledTimes(1)
expect(previewRender).toHaveBeenCalledTimes(3)
})
})
@@ -1,10 +1,15 @@
// @vitest-environment jsdom
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 { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
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'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import type { TranscriptViewMode } from '../src/chat-settings.ts'
import { derivePresentationPolicy } from '../src/client/presentation-policy.ts'
import { ReasoningRow } from '../src/client/chat/ReasoningRow.tsx'
afterEach(() => {
cleanup()
@@ -14,22 +19,81 @@ const t = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
describe('ReasoningRow', () => {
it.each([
['', ''],
['An unfinished line', ''],
['A completed first line\nUnfinished continuation', 'A completed first line'],
['First\nSecond\nThird\n', 'First'],
['First\n\nNext unfinished', 'First'],
['First\n\nNext complete\nDetails', 'Next complete'],
['First\r\n \t\r\nNext complete\r\n', 'Next complete'],
['First\n\n\n', 'First'],
])('previews the completed paragraph first line for %j', (text, summary) => {
const view = render(<ReasoningRow text={text} running usePresentation={useDetailedPresentation} t={t} />)
const root = view.container.querySelector('[data-variant="think"]')!
expect(root.querySelector('[class*="summaryText"]')?.textContent).toBe(summary)
expect(root.hasAttribute('data-preview')).toBe(summary !== '')
})
it('retains summaries and expanded Markdown when the work-details mode changes', () => {
const mode = createSnapshotStore<TranscriptViewMode>('detailed')
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
const view = render(<ReasoningRow
text={'First line\n\nDetailed body'} running={false} usePresentation={usePresentation} t={t} />)
const root = view.container.querySelector('[data-variant="think"]')!
const summary = view.getByText('First line')
const toggle = view.getByRole('button')
for (const next of ['compact', 'detailed', 'expanded', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('First line')).toBe(summary)
expect(root.hasAttribute('data-preview')).toBe(next !== 'compact')
expect(root.querySelector('[data-markdown-variant]')).toBeNull()
}
fireEvent.click(toggle)
const body = view.getByText('Detailed body')
for (const next of ['detailed', 'expanded', 'compact'] as const) {
act(() => { mode.set(next) })
expect(view.getByText('Detailed body')).toBe(body)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(root.hasAttribute('data-preview')).toBe(false)
}
})
it('keeps a running preview enabled in Compact and hides it on settlement without removing it', () => {
const mode = createSnapshotStore<TranscriptViewMode>('compact')
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode))
const props = { text: 'First line\n', usePresentation, t }
const view = render(<ReasoningRow {...props} running />)
const summary = view.getByText('First line')
const root = view.container.querySelector('[data-variant="think"]')!
expect(root.hasAttribute('data-preview')).toBe(true)
view.rerender(<ReasoningRow {...props} running={false} />)
expect(root.hasAttribute('data-preview')).toBe(false)
expect(view.getByText('First line')).toBe(summary)
})
it.each([
{ kind: 'text' as const, text: 'Answer' },
{ kind: 'tool-call' as const, callId: 'call-1', name: 'read', argsRaw: '{}' },
])('starts collapsed and preserves manual expansion when $kind arrives', (nextBlock) => {
const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' }
const view = render(
<AssistantMarkdown t={t} blocks={[reasoning]} streaming renderMessageImages={renderMessageImages} />,
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t} blocks={[reasoning]} streaming renderMessageImages={renderMessageImages} />,
)
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
fireEvent.click(view.getByText('思考'))
view.rerender(
<AssistantMarkdown t={t} blocks={[reasoning, nextBlock]} streaming renderMessageImages={renderMessageImages} />,
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t} blocks={[reasoning, nextBlock]} streaming renderMessageImages={renderMessageImages} />,
)
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
view.rerender(
<AssistantMarkdown t={t} blocks={[reasoning, nextBlock]} streaming={false} renderMessageImages={renderMessageImages} />,
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t} blocks={[reasoning, nextBlock]} streaming={false} renderMessageImages={renderMessageImages} />,
)
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
@@ -37,35 +101,52 @@ describe('ReasoningRow', () => {
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
})
it('follows the latest streaming line, then restores the settled first line', () => {
it('advances on completed paragraph first lines, then restores the settled first line', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\n' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('运行中')).toBeTruthy()
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-streaming'))
.toBe('true')
view.rerender(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries' }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Newest reasoning tokens keep arriving').parentElement
?.getAttribute('data-follow-end')).toBe('true')
expect(view.getByText('Newest reasoning tokens').parentElement
?.getAttribute('data-streaming')).toBe('true')
expect(view.queryByText('Checking boundaries')).toBeNull()
const text = 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries\n'
view.rerender(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text }]}
streaming
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Checking boundaries')).toBeTruthy()
expect(view.queryByText('Newest reasoning tokens')).toBeNull()
view.rerender(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
blocks={[{ kind: 'reasoning', text }]}
streaming={false}
renderMessageImages={renderMessageImages}
/>,
@@ -73,12 +154,13 @@ describe('ReasoningRow', () => {
const settledSummary = view.getByText('Inspect the session')
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('运行中')).toBeNull()
expect(settledSummary.parentElement?.hasAttribute('data-follow-end')).toBe(false)
expect(settledSummary.parentElement?.hasAttribute('data-streaming')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
@@ -103,12 +185,13 @@ describe('ReasoningRow', () => {
},
{
label: 'streaming',
text: 'Inspect the session\n**Comparing checkout and merge bases**',
text: 'Inspect the session\n\n**Comparing checkout and merge bases**\n',
streaming: true,
},
])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text }]}
streaming={streaming}
@@ -129,6 +212,7 @@ describe('ReasoningRow', () => {
.join('\n\n') + '\n\nReasoning body.'
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text }]}
streaming={false}
@@ -154,6 +238,7 @@ describe('ReasoningRow', () => {
const first = '## Investigation\n\n**Check persistence**\n\n'
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: first }]}
streaming
@@ -166,6 +251,7 @@ describe('ReasoningRow', () => {
const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n')
view.rerender(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text }]}
streaming
@@ -180,6 +266,7 @@ describe('ReasoningRow', () => {
it('expanded Think drops the inline summary and renders prose without an IN card', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
@@ -196,6 +283,7 @@ describe('ReasoningRow', () => {
it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => {
const view = render(
<AssistantMarkdown
usePresentation={useDetailedPresentation}
t={t}
blocks={[
{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' },
@@ -20,19 +20,19 @@ describe('TranscriptViewPolicy', () => {
current = () => policy.mode.getSnapshot()
expect(policy.mode.getSnapshot()).toBe('compact')
policy.setMode('normal')
expect(policy.mode.getSnapshot()).toBe('normal')
expect(observed).toEqual(['transcriptView=normal:normal'])
expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal')
policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('detailed')
expect(observed).toEqual(['transcriptView=detailed:detailed'])
expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
})
it('adopts Host state and ignores identical writes', () => {
it('adopts Host state, reads the legacy value as Detailed, and ignores identical writes', () => {
const host = stubSettingsScope<ChatSettings>()
const policy = new TranscriptViewPolicy(host.scope)
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(policy.mode.getSnapshot()).toBe('normal')
policy.setMode('normal')
expect(policy.mode.getSnapshot()).toBe('detailed')
policy.setMode('detailed')
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
@@ -41,7 +41,7 @@ describe('TranscriptViewPolicy', () => {
it('adopts an accepted section standing at construction', () => {
const host = stubSettingsScope<ChatSettings>()
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal')
host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded')
})
})
@@ -9,7 +9,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx'
import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts'
import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts'
import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx'
import { en, zh } from '../src/client/locale.ts'
@@ -34,9 +34,9 @@ function noPendingInteraction() {
// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) {
const source = createSnapshotStore(mode)
const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
function mount(mode: TranscriptViewMode = 'compact', dictionary: typeof en | typeof zh = en) {
const source = createSnapshotStore<TranscriptViewMode>(mode)
const setTranscriptView = vi.fn((next: TranscriptViewMode) => { source.set(next) })
const props: TranscriptViewRowProps = {
usePanelInfo: selector => selector({ activePanelId: null }),
useSessions: emptySessions(),
@@ -55,28 +55,37 @@ function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | t
describe('TranscriptViewRow', () => {
it('explains the preference and shows Compact by default', () => {
mount()
expect(screen.getByText('Conversation display')).toBeDefined()
expect(screen.getByText('Controls process content in completed turns')).toBeDefined()
expect(screen.getByText('Work details')).toBeDefined()
expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined()
expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
})
it('selects Normal and follows the mirrored value', () => {
it.each([
['detailed', 'Detailed'],
['expanded', 'Expanded'],
] as const)('selects %s and follows the mirrored value', (mode, label) => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' }))
expect(b.setTranscriptView).toHaveBeenCalledWith('normal')
const trigger = screen.getByRole('button', { name: /Normal/ })
expect(screen.queryByRole('menuitem', { name: 'Normal' })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: label }))
expect(b.setTranscriptView).toHaveBeenCalledWith(mode)
const trigger = screen.getByRole('button', { name: label })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
})
it('shows the conversation-display values in Chinese', () => {
mount('compact', zh)
fireEvent.click(screen.getByRole('button', { name: '紧凑' }))
fireEvent.click(screen.getByRole('menuitem', { name: '标准' }))
expect(screen.getByRole('button', { name: '标准' })).toBeDefined()
it('shows all three work-detail values in Chinese', () => {
const b = mount('compact', zh)
expect(screen.getByText('工作过程展示')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: '简洁' }))
fireEvent.click(screen.getByRole('menuitem', { name: '详细' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('detailed')
fireEvent.click(screen.getByRole('button', { name: '详细' }))
fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' }))
expect(b.setTranscriptView).toHaveBeenLastCalledWith('expanded')
expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined()
})
})
@@ -286,7 +286,7 @@ export const zh = {
'row.inspect': '查看',
'tool.title.search': '搜索',
'tool.title.read': '读取',
'tool.title.bash': 'Bash',
'tool.title.bash': '运行命令',
'tool.title.write': '写入',
'tool.title.edit': '编辑',
'tool.title.code': '代码',
@@ -295,10 +295,10 @@ export const zh = {
'tool.title.runCordis': '运行 Cordis 插件',
'tool.title.stopCordis': '停止 Cordis 插件',
'tool.title.removeCordis': '移除 Cordis 插件',
'tool.title.pwsh': 'Pwsh',
'tool.title.pwsh': '运行命令',
'tool.title.readImage': '读取图片',
'tool.title.grep': 'Grep',
'tool.title.glob': 'Glob',
'tool.title.grep': '搜索文件内容',
'tool.title.glob': '查找文件',
'tool.title.webSearch': '网页搜索',
'tool.title.webFetch': '网页获取',
'tool.autoReviewRejected': 'Auto review 已拒绝',
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 12fe2648e383189198ff972709819e6f1eb4c02f
README.zh.md: 3c6f62fee22c5ad7e750c860724f60ca6dd9fbcf
README.md: 2606bd0dd5895319c9887b15497b2ca6b7a045ce
README.zh.md: cdf816549ea6cf12382b819a493ded7f61ecddd9
+1 -1
View File
@@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. |
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
| `Modal` | Centered dialog over a page mask. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `OnboardingSurface` | First-run stage that holds the application root inert. |
+1 -1
View File
@@ -46,7 +46,7 @@ kind: "package-library"
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 |
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
| `Modal` | 页面遮罩之上的居中对话框。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
@@ -1,6 +1,7 @@
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutlineRegular } from './icons/index.tsx'
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx'
import { TextShimmer } from './TextShimmer.tsx'
import css from './DisclosureRow.module.css'
/** Shared 24px disclosure chrome for compact flow rows. */
@@ -10,6 +11,8 @@ export interface DisclosureRowProps {
open: boolean
expandable: boolean
onToggle: () => void
/** Animate the title while its owning operation is running. */
running?: boolean | undefined
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
/** Replaces the collapsed icon with a chevron while the row is hovered. */
@@ -27,15 +30,17 @@ export interface DisclosureRowProps {
/**
* Render one disclosure header and its controlled expanded content.
* Shallow prop comparison requires stable callbacks and React nodes to skip unchanged renders.
* @param props - Visual content, controlled state, and interaction policy.
* @returns the disclosure row.
*/
export function DisclosureRow({
export const DisclosureRow = memo(function DisclosureRow({
icon,
title,
open,
expandable,
onToggle,
running = false,
expandOnRowClick = false,
previewChevron = expandable,
keepContentWhenOpen = false,
@@ -66,7 +71,7 @@ export function DisclosureRow({
)
: icon
const leading = open
? <IconChevronDownOutlineRegular className={chevronClassName} />
? <IconChevronUpOutlineRegular className={chevronClassName} />
: collapsedLeading
return (
@@ -95,10 +100,10 @@ export function DisclosureRow({
{leading}
</span>
)}
<span className={clsx(css.title, titleClassName)}>{title}</span>
<TextShimmer className={clsx(css.title, titleClassName)} active={running}>{title}</TextShimmer>
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>
)
}
})
@@ -0,0 +1,27 @@
.root[data-text-shimmer] {
background-image: linear-gradient(
90deg,
currentColor calc(50% - var(--dsh-text-shimmer-spread)),
color-mix(in oklab, currentColor 50%, transparent),
currentColor calc(50% + var(--dsh-text-shimmer-spread))
);
background-position: 100% center;
background-repeat: no-repeat;
background-size: 250% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: dsh-text-shimmer 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite;
}
@keyframes dsh-text-shimmer {
66.6667%, 100% { background-position: 0% center; }
}
@media (prefers-reduced-motion: reduce) {
.root[data-text-shimmer] {
background-image: none;
-webkit-text-fill-color: currentColor;
animation: none;
}
}
@@ -0,0 +1,23 @@
/** Text-only activity animation with a stable span across lifecycle changes. */
import { memo, useMemo, type CSSProperties } from 'react'
import clsx from 'clsx'
import css from './TextShimmer.module.css'
/** Text and activity supplied by the owning row. */
export interface TextShimmerProps {
children: string
active: boolean
className?: string | undefined
}
/**
* Render text with an optional moving highlight; inactive text keeps the same node.
* @param props - localized text, running state, and owner styling.
* @returns the retained text span.
*/
export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) {
const style = useMemo(() => ({
'--dsh-text-shimmer-spread': `${children.length * 8}px`,
} as CSSProperties), [children.length])
return <span className={clsx(css.root, className)} style={style} data-text-shimmer={active || undefined}>{children}</span>
})
@@ -6,6 +6,8 @@ export { StateDot } from './StateDot.tsx'
export type { StateDotState } from './StateDot.tsx'
export { DisclosureRow } from './DisclosureRow.tsx'
export type { DisclosureRowProps } from './DisclosureRow.tsx'
export { TextShimmer } from './TextShimmer.tsx'
export type { TextShimmerProps } from './TextShimmer.tsx'
export { Button } from './Button.tsx'
export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
@@ -0,0 +1,16 @@
/** CSS activity and reduced-motion rules that jsdom does not execute. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/TextShimmer.module.css', import.meta.url), 'utf8')
describe('TextShimmer styles', () => {
it('animates active text and disables motion and transparent fill for reduced motion', () => {
expect(css).toMatch(/\.root\[data-text-shimmer\]\s*\{[^}]*animation: dsh-text-shimmer/s)
expect(css).toMatch(/@keyframes dsh-text-shimmer\s*\{[^}]*background-position: 0% center/s)
const reduced = /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.root\[data-text-shimmer\]\s*\{([^}]+)\}/.exec(css)?.[1]
expect(reduced).toContain('background-image: none;')
expect(reduced).toContain('-webkit-text-fill-color: currentColor;')
expect(reduced).toContain('animation: none;')
})
})
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { TextShimmer } from '../src/TextShimmer.tsx'
afterEach(cleanup)
describe('TextShimmer', () => {
it('retains its text span across activity, text, and owner-class changes', () => {
const view = render(<TextShimmer active className="title">Run</TextShimmer>)
const span = view.getByText('Run')
expect(span.tagName).toBe('SPAN')
expect(span.dataset.textShimmer).toBe('true')
expect(span.classList.contains('title')).toBe(true)
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px')
view.rerender(<TextShimmer active={false}>End</TextShimmer>)
expect(view.getByText('End')).toBe(span)
expect(span.hasAttribute('data-text-shimmer')).toBe(false)
expect(span.classList.contains('title')).toBe(false)
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px')
view.rerender(<TextShimmer active>Running</TextShimmer>)
expect(view.getByText('Running')).toBe(span)
expect(span.dataset.textShimmer).toBe('true')
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px')
})
})
@@ -5,7 +5,7 @@ export const NS = 'skill'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'row.title': 'Skill',
'row.title': '加载技能',
'row.running': '正在加载 skill',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
@@ -132,7 +132,7 @@ describe('apply', () => {
expect(presentation.dictionaries).toEqual([{
namespace: 'skill', dictionaries: {
zh: {
'row.title': 'Skill',
'row.title': '加载技能',
'row.running': '正在加载 skill',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
@@ -51,7 +51,7 @@ describe('SkillRow', () => {
it('renders a compact Bash-shaped summary and discloses the exact instructions', () => {
const inspect = vi.fn()
const view = render(<SkillRow {...props(settled(), inspect)} />)
const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' })
const row = screen.getByRole('button', { name: '加载技能dsh-manage-issues' })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok')
expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14')
@@ -95,7 +95,7 @@ describe('SkillRow', () => {
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' })
const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' })
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull()
expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull()
@@ -128,7 +128,7 @@ describe('SkillRow', () => {
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' })
const errorRow = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing' })
fireEvent.click(errorRow)
expect(screen.getAllByText('SkillError: missing')).toHaveLength(2)
})
@@ -11,37 +11,6 @@
flex-direction: column;
}
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
}
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-tool-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
@@ -67,6 +36,7 @@
.title {
font-weight: 400;
transition: color 100ms ease;
}
.sep {
@@ -87,6 +57,7 @@
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
@@ -101,6 +72,13 @@
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.row:hover .title,
.row:hover .summary:not(.errorSummary),
.row:hover .summarySuffix {
color: var(--dsw-alias-label-primary);
}
/* Diff-row +/- totals: the code font, matching the diff body the numbers
@@ -143,8 +121,10 @@
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
transition: color 100ms ease;
}
.row:hover .fileLink,
.fileLink:hover {
color: var(--dsw-alias-label-primary);
text-decoration-color: currentColor;
@@ -360,8 +340,9 @@
border: 0.5px solid var(--dsw-alias-border-l1);
}
/* Visually hidden run-state label for assistive technology: the running sweep
and settlement summary tones are color-only. */
/* Visually hidden run-state label for assistive technology: the business icon
and animated text treatment do not name the state, so this text carries the
running/failed/interrupted state to a screen reader. */
.visuallyHidden {
position: absolute;
width: 1px;
@@ -1,8 +1,8 @@
import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { memo, useCallback, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock,
TerminalBlock, WebBlock,
TerminalBlock, TextShimmer, WebBlock,
diffTotals,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -101,7 +101,12 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin
}
}
export function ToolRow({
/**
* Render one localized tool summary and lazily mounted result card.
* @param props - tool state, summary, output, and navigation callbacks.
* @returns the tool disclosure.
*/
export const ToolRow = memo(function ToolRow({
t,
variant,
toolName,
@@ -134,9 +139,9 @@ export function ToolRow({
const readLabels = useMemo(() => readBlockLabels(t), [t])
const searchLabels = useMemo(() => searchBlockLabels(t), [t])
const webLabels = useMemo(() => webBlockLabels(t), [t])
const terminalBody = terminal === undefined || terminal === null
const terminalBody = useMemo(() => terminal === undefined || terminal === null
? null
: localizeTerminalCardModel(terminal, t)
: localizeTerminalCardModel(terminal, t), [terminal, t])
const diffBody = diff ?? null
const readBody = read ?? null
const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined
@@ -156,6 +161,7 @@ export function ToolRow({
[card, inputRaw, open, variant],
)
const status = stateStatus(state, t)
const running = state === 'running'
const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary)
// A failure keeps its first result line when available and otherwise turns
// the ordinary summary red. An interruption turns the tool-owned summary
@@ -172,26 +178,159 @@ export function ToolRow({
}, [diffBody])
const settledWithCue = state === 'error' || state === 'stopped'
const suffix = settledWithCue ? null : summarySuffix ?? diffStat
const toggleExpand = () => {
const toggleExpand = useCallback(() => {
setExpanded(v => !v)
}
const openFile = filePath !== undefined && onOpenFile !== undefined && !settledWithCue
}, [])
const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue
? (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePathLine === undefined) onOpenFile(filePath)
else onOpenFile(filePath, { line: filePathLine })
}
: undefined
: undefined, [filePath, filePathLine, onOpenFile, settledWithCue])
// Keep Enter/Space on the focused path link from bubbling to the row's
// keydown handler, which would preventDefault() the key and toggle expand
// instead of activating the link — the keyboard analogue of openFile's
// stopPropagation. The native button still fires its own onClick from the key.
const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const fileLinkKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
}
}, [])
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : bodyText
const collapsedContent = useMemo(() => summaryText !== '' && (
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} aria-hidden />
{openFile !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : (
<span
className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}
>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</span>
)}
{suffix !== null && (
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText])
const expandedContent = useMemo(() => open ? (
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
? <AskQuestionCard card={askQuestionBody} />
: terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalLabels}
className={css.terminalBody}
/>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: readBody !== null
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
: imageBody !== null
? (
/* Label, gallery, then the result's OWN envelope text. The text
comes from the image card model (which reads the result's text
block), never from the row's flattened output: an image read's
content is [text envelope, image block] and flattening
JSON.stringifies the image block, printing the raw attachment
object under the picture. It is not redundant either — the
attachment slot can render nothing, and then this line is the
only evidence an image was returned. */
<div className={css.imageBody}>
<div className={css.imageLabel}>{imageBody.label}</div>
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
images: imageBody.images,
loadImage,
align: 'start',
})}
<div className={css.imageMeta}>{imageBody.text}</div>
</div>
)
: searchBody !== null
? (
<>
<SearchBlock
{...searchBody.card}
labels={searchLabels}
maxLines={CHAT_SEARCH_MAX_LINES}
className={css.searchBody}
/>
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)
: webBody !== null
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: detailsBody !== null
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
: (
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.input')}</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.output')}</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspectOutlineRegular />
{t('row.inspect')}
</button>
)}
</div>
) : undefined, [
open, detailsBody, askQuestionBody, terminalBody, terminalLabels, diffBody, diffLabels, readBody, readLabels,
imageBody, renderSlot, loadImage, searchBody, searchLabels, webBody, webLabels, inspect, t, onOpenFile,
variant, bodyText, cardBody, outputText, state,
])
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
@@ -202,140 +341,16 @@ export function ToolRow({
chevronClassName={css.chevron}
icon={icon}
title={title}
running={running}
open={open}
expandable={expandable}
expandOnRowClick
keepContentWhenOpen
onToggle={toggleExpand}
collapsedContent={summaryText !== '' && (
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} aria-hidden />
{openFile !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
{summaryText}
</button>
) : (
<span
className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}
>
{summaryText}
</span>
)}
{suffix !== null && (
<span className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)}>{suffix}</span>
)}
</>
)}
collapsedContent={collapsedContent}
>
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
? <AskQuestionCard card={askQuestionBody} />
: terminalBody !== null
? (
<TerminalBlock
{...terminalBody.card}
maxLines={Infinity}
labels={terminalLabels}
className={css.terminalBody}
/>
)
: diffBody !== null
? <DiffBlock {...diffBody.card} labels={diffLabels} maxLines={CHAT_DIFF_MAX_LINES} className={css.diffBody} />
: readBody !== null
? <ReadBlock {...readBody} labels={readLabels} maxLines={CHAT_READ_MAX_LINES} className={css.readBody} />
: imageBody !== null
? (
/* Label, gallery, then the result's OWN envelope text. The text
comes from the image card model (which reads the result's text
block), never from the row's flattened output: an image read's
content is [text envelope, image block] and flattening
JSON.stringifies the image block, printing the raw attachment
object under the picture. It is not redundant either — the
attachment slot can render nothing, and then this line is the
only evidence an image was returned. */
<div className={css.imageBody}>
<div className={css.imageLabel}>{imageBody.label}</div>
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', {
images: imageBody.images,
loadImage,
align: 'start',
})}
<div className={css.imageMeta}>{imageBody.text}</div>
</div>
)
: searchBody !== null
? (
<>
<SearchBlock
{...searchBody.card}
labels={searchLabels}
maxLines={CHAT_SEARCH_MAX_LINES}
className={css.searchBody}
/>
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)
: webBody !== null
? <WebBlock {...webBody} labels={webLabels} className={css.webBody} />
: detailsBody !== null
? <ToolDetails model={detailsBody} hasInspect={inspect !== undefined} t={t} onOpenFile={onOpenFile} />
: (
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.input')}</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>{t('row.output')}</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspectOutlineRegular />
{t('row.inspect')}
</button>
)}
</div>
{expandedContent}
</DisclosureRow>
</div>
)
}
})
@@ -85,37 +85,12 @@
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: calc(24px + var(--dsh-content-font-delta, 0px));
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-bash-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
@@ -172,6 +147,7 @@
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-secondary);
transition: color 100ms ease;
}
.sep {
@@ -192,6 +168,12 @@
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.root:hover .title,
.root:hover .summary:not(.errorSummary) {
color: var(--dsw-alias-label-primary);
}
/* Error row's collapsed summary: the failure's first line in the error color. */
@@ -1,8 +1,9 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import { memo, useCallback, useMemo, useState, type KeyboardEvent } from 'react'
import type { Context } from '@deepseek-ai/cordis'
import clsx from 'clsx'
import {
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconInspectOutlineRegular, TerminalBlock,
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular,
TerminalBlock, TextShimmer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
@@ -20,23 +21,31 @@ import css from './bash-sample.module.css'
type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'>
const BASH_ICON = <IconApiOutlineRegular size={14} />
/** Visually hidden status for the color-only running sweep and error tone. */
function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null {
switch (state) {
case 'running': return t('bash.running')
case 'error': return t('bash.failed')
case 'stopped': return t('bash.stopped')
default: return null
}
}
/** Renders expandable Bash output with an accessible lifecycle label. */
export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
const model = toolRowModel(toolName, block)
/**
* Render expandable Bash output with an accessible lifecycle label.
* @param props - tool call, Session sources, locale, and inspection callback.
* @returns the Bash output row.
*/
export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) {
const model = useMemo(() => toolRowModel(toolName, block), [toolName, block])
// An omitted shell workdir is the session workspace; relative values resolve
// against it before reaching the terminal primitive.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminalModel = terminalCardModel(block, cwd)
const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)
const terminalModel = useMemo(() => terminalCardModel(block, cwd), [block, cwd])
const terminal = useMemo(() => terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t), [terminalModel, t])
const labels = useMemo(() => terminalBlockLabels(t), [t])
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced through the row's error summary.
const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel)
@@ -61,25 +70,25 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
const settlementLine = state === 'error'
? model.errorSummary ?? normalSummary
: state === 'stopped' ? t('bash.stopped') : null
const toggleExpand = () => {
const running = state === 'running'
const toggleExpand = useCallback(() => {
setExpanded(v => !v)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
}, [])
const toggleFromKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const businessIcon = <IconApiOutlineRegular size={14} />
}, [expandable, toggleExpand])
const leading = open
? <IconChevronDownOutlineRegular className={css.chevron} />
? <IconChevronUpOutlineRegular className={css.chevron} />
: expandable
? (
<>
<span className={css.iconIdle}>{businessIcon}</span>
<span className={css.iconIdle}>{BASH_ICON}</span>
<IconChevronDownOutlineRegular className={clsx(css.chevron, css.chevronHover)} />
</>
)
: businessIcon
: BASH_ICON
return (
<div className={css.card}>
<div
@@ -96,14 +105,14 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{t(model.titleKey)}</span>
<TextShimmer className={css.title} active={running}>{t(model.titleKey)}</TextShimmer>
<span className={css.sep} aria-hidden />
<span className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}>
{settlementLine ?? normalSummary}
<TextShimmer active={running}>{settlementLine ?? normalSummary}</TextShimmer>
</span>
</div>
{open && (
@@ -113,7 +122,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
<TerminalBlock
{...terminal.card}
maxLines={Infinity}
labels={terminalBlockLabels(t)}
labels={labels}
className={css.terminal}
/>
)
@@ -148,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)}
</div>
)
}
})
/** Registers the standalone Bash conversation-row sample. */
export const bashToolviewSample = {
@@ -48,7 +48,7 @@ describe('Tool presentation tails', () => {
)
expect(view.queryByTestId('icon')).not.toBeNull()
const summary = view.getByText('s')
expect(summary.className).toContain('stoppedSummary')
expect(summary.parentElement?.className).toContain('stoppedSummary')
})
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
@@ -76,7 +76,7 @@ describe('Tool presentation tails', () => {
}
const view = render(<BashRow {...bashProps(settled)} />)
const row = view.container.querySelector('[data-sample="bash"]')!
expect(row.textContent).toContain('Bash')
expect(row.textContent).toContain('运行命令')
expect(row.textContent).toContain('Build')
expect(row.getAttribute('data-clickable')).toBeNull()
})
@@ -99,7 +99,7 @@ describe('Tool presentation tails', () => {
const runningView = render(<BashRow {...bashProps(running)} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(runningView.getByText('Bash')).toBeTruthy()
expect(runningView.getByText('运行命令')).toBeTruthy()
expect(runningView.getByText('List')).toBeTruthy()
runningView.unmount()
@@ -107,14 +107,15 @@ describe('Tool presentation tails', () => {
expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"] svg')).not.toBeNull()
expect(errorView.getByText('Bash')).toBeTruthy()
expect(errorView.getByText('运行命令')).toBeTruthy()
expect(errorView.container.querySelector('[class*="_errorSummary_"]')).not.toBeNull()
errorView.unmount()
const stoppedView = render(<BashRow {...bashProps(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.container.querySelector('[data-state="stopped"] svg')).not.toBeNull()
const stoppedSummary = stoppedView.getByText('已停止')
expect(stoppedSummary.className).toContain('stoppedSummary')
const stoppedSummary = stoppedView.getByRole('button').querySelector('[class*="_stoppedSummary_"]')
expect(stoppedSummary?.textContent).toBe('已停止')
expect(stoppedView.getAllByText('已停止')).toHaveLength(2)
})
})
@@ -236,7 +236,7 @@ describe('SearchRow keyed card', () => {
it('collapses to the summary row; expanding reveals the grep card', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.getByText('Grep')).toBeTruthy()
expect(view.getByText('搜索文件内容')).toBeTruthy()
expect(view.queryByText('Search')).toBeNull()
// Collapsed: the card is not in the DOM until the row is expanded.
expect(searchKindOf(view.container)).toBeNull()
@@ -250,7 +250,7 @@ describe('SearchRow keyed card', () => {
it('expands to the glob path card', () => {
const view = render(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
expect(view.getByText('Glob')).toBeTruthy()
expect(view.getByText('查找文件')).toBeTruthy()
expect(view.queryByText('Search')).toBeNull()
expect(searchKindOf(view.container)).toBeNull()
toggleRow(view)
@@ -81,10 +81,10 @@ describe('tool-call-model', () => {
expect(t(toolRowModel('cordis_unmount', running({ name: 'cordis_unmount', argsRaw: '{}' })).titleKey)).toBe('工具调用')
})
it('gives the pwsh shell row the bash family treatment with its own title', () => {
it('gives the pwsh shell row the bash family treatment and localized command title', () => {
const m = toolRowModel('pwsh', running())
expect(m.variant).toBe('bash')
expect(t(m.titleKey)).toBe('Pwsh')
expect(t(m.titleKey)).toBe('运行命令')
})
it('derives state across running/ok/error/interrupted', () => {
@@ -96,7 +96,7 @@ describe('tool-call-model', () => {
it('derives the bash summary from description over command', () => {
const m = toolRowModel('bash', running())
expect(t(m.titleKey)).toBe('Bash')
expect(t(m.titleKey)).toBe('运行命令')
expect(m.summary).toBe('List files')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd')
})
@@ -420,7 +420,7 @@ describe('ToolRow', () => {
it('an error row without an error summary keeps the args summary', () => {
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
const summary = view.getByText('List files')
expect(summary.className).toContain('errorSummary')
expect(summary.parentElement?.className).toContain('errorSummary')
})
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
@@ -499,7 +499,7 @@ describe('GenericToolCard', () => {
it('renders the classified variant row from the frozen slice', () => {
const view = render(<GenericToolCard {...props('bash', result())} />)
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('运行命令')).toBeTruthy()
expect(view.getByText('List files')).toBeTruthy()
expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull()
})
@@ -556,7 +556,7 @@ describe('GenericToolCard', () => {
it('passes the owner inspect callback through to the expanded row pill', () => {
const inspect = vi.fn()
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByRole('button', { name: /运行命令/ }))
fireEvent.click(view.getByText('查看'))
expect(inspect).toHaveBeenCalledTimes(1)
})
@@ -176,7 +176,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:227',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:243',
},
{
key: 'conversation.chat.commandview',
@@ -224,7 +224,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:215',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:231',
},
{
key: 'conversation.chat.node',
@@ -293,7 +293,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:196',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:212',
},
{
key: 'conversation.chat.turnTail',
@@ -354,7 +354,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\', 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:221',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:237',
},
{
key: 'conversation.composer',
@@ -1126,7 +1126,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:209',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:225',
},
{
key: 'conversation.plan-review.actions',
@@ -5,7 +5,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}"
- button "Copy"
- button "Context injection runtime-context"
@@ -1,14 +1,13 @@
- button "系统提示词"
- text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}}
- button "复制"
- button "7 次工具调用" [expanded]
- button "上下文注入 runtime-context"
- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it."
- button "思考"
- button "读取 intro.md":
- text: 读取
- button "intro.md"
- button "Bash List files and show notes.txt"
- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append."
- button "运行命令 List files and show notes.txt"
- button "思考"
- button "编辑 intro.md +1 -1":
- text: 编辑
- button "intro.md"
@@ -21,8 +20,8 @@
- text: 写入
- button "app.local"
- text: +1 -0
- button "Bash Append done to notes.txt"
- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."'
- button "运行命令 Append done to notes.txt"
- button "思考"
- button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt"
- paragraph: 四件事全部完成:
- list:
@@ -6,7 +6,6 @@
- tablist:
- tab "对话" [selected]
- tab "轨迹"
- button "系统提示词"
- text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}}
- button "复制"
- button "已思考"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy"
- button "1 tool call"
@@ -6,12 +6,11 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy"
- button "Thought for a while" [expanded]
- button "Context injection runtime-context"
- button "Think The user wants me to reply with a single word. Let me comply."
- button "Think"
- paragraph: LIGHTHOUSE
- button "Copy"
- button "Good response"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy"
- button "Thought for a while"
@@ -6,12 +6,11 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy"
- button "Thought for a while" [expanded]
- button "Context injection runtime-context"
- button "Think The user wants me to reply with a single word. Let me comply."
- button "Think"
- paragraph: LIGHTHOUSE
- button "Copy"
- button "Good response"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "Copy"
- button "Thought for a while"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: poem.txt TXT 16B
- button "reference-1.png, click to view original":
- img "reference-1.png"
@@ -6,14 +6,13 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy"
- button "1 tool call" [expanded]
- button "Context injection runtime-context"
- button "Think The user wants me to run a simple bash command and reply with \"DONE\"."
- button "Think"
- button "Bash Echo the test string"
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."
- button "Think"
- paragraph: DONE
- button "Copy"
- button "Good response"
@@ -6,7 +6,6 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy"
- button "1 tool call"
@@ -11,22 +11,21 @@
- button "Jump to turn 2"
- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的
- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"'
- button "System prompt"
- button "6 tool calls" [expanded]
- button "Context injection goal"
- button "Context injection runtime-context"
- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""'
- button "Think"
- button "Bash List package directories deterministically"
- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."'
- button "Think"
- button "Bash List packages directory contents"
- button "Think There are 4 packages:"
- button "Think"
- button "Failed Bash Randomly pick one package directory"
- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."'
- button "Think"
- button "Bash Randomly select one package with Python"
- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."'
- button "Think"
- button "Bash List all files in session-reference package"
- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."'
- button "Think"
- paragraph:
- strong: Turn 1 / 2
- text: — 随机选中的包:
@@ -42,15 +41,14 @@
- button "Bad response"
- button "Branch into a new conversation"
- text: {{clock}}
- button "System prompt"
- button "5 tool calls · 2 messages" [expanded]
- button "Context injection goal"
- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."'
- button "Think"
- button "Bash Randomly select another package with Python"
- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."'
- button "Think"
- button "Bash Re-roll excluding the package from turn 1"
- button "Bash List all files in token-meter package"
- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."'
- button "Think"
- paragraph:
- strong: Turn 2 / 2
- text: — 随机选中的包:
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more