diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml new file mode 100644 index 0000000000..bb3a7b1cbe --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md new file mode 100644 index 0000000000..df0397a056 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -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(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. diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md new file mode 100644 index 0000000000..1bf9cf9517 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -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(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 表达的用例。 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml new file mode 100644 index 0000000000..1f41262d74 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml @@ -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 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md new file mode 100644 index 0000000000..cd5dbe7234 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -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. diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md new file mode 100644 index 0000000000..4055b027fc --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -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 物化路线仍需继续评估;本记录不将实验实现列为最终架构。 diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 810336de88..de94289607 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -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 => ( diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 2ddd03bddb..1b37b0484f 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 1350afec53..6c9e8eeaa5 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 4abbc3d013..6fbf824a1b 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -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" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 7c18886de2..1b30edc3e0 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -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" diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 1337d351d5..3f945d9cce 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -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" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md index 1337d351d5..3f945d9cce 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -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" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 74db4241cd..c0a1b2ed35 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -18,8 +18,8 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑" + - text: px 工作过程展示 控制轮次和步骤的默认展开方式 + - button "简洁" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 1876f63c32..9540d8b21f 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -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}} diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b66832d25e..a1a363ff59 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -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}} diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index f3e9564660..13cc723a67 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -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" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 9538b35054..0cbd3e6544 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -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" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 870a712c04..92445bc6c1 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -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" diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 9b6ebf857f..1361a476d3 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -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) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 241d90707c..0576d26412 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -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() + const reasoningComplete = Promise.withResolvers() 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 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index fd4a0b2e9c..a4f07bb745 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -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) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index bde54fe69e..270f19a181 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -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 { 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' }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 48b505f398..7604dba970 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -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 () => { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index b31b33bba0..e63471911d 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -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 ` — 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) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 0c6e79308a..050a928221 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -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') diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 27e4c283ae..19c2becc76 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -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" diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7ea13e1c9c..f4f33e8f1d 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -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 }) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 7d9e60da65..489c1e8723 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index eea8eeda01..3a0e74332a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 58fef8e8c4..89a385a61f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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 默认收起。 ----- diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index 021a2b0616..ccc9bc55fd 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -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 = 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), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 079a5e2512..70390ec889 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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), diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 13ff7fae4b..967a7a09b1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -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} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 399897a9b5..9ef72941f5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -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 + /** 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} diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 40e08dbaf7..3ed6f7b8d8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -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) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 257dad64ab..bad68eb828 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -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} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index 513a826c46..a4bb6ef0cc 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -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; - } -} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 2864836301..0b0749881b 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -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 = + /** 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(() => ( + <> + + + {summary} + + + ), [running, state, summary]) + const content = useMemo(() => open + ?
{body}
+ : undefined, [body, open, state]) return (
{state === 'running' && {t('row.running')}} @@ -40,22 +60,18 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={} + icon={COMMAND_ICON} title={title} + running={running} open={open} expandable={body !== null} expandOnRowClick keepContentWhenOpen - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - {summary} - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
{body}
+ {content}
) -} +}) diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 3ca51f8d85..f59c04e404 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -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)); diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 4d484af95b..53d1ab2a55 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -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 = + 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(() => ( + <> + + + {summary} + + + ), [running, summary]) + const content = useMemo(() => expanded ? ( +
+ +
+ ) : undefined, [expanded, labels, running, text]) return (
{running && {t('row.running')}} } + icon={THINK_ICON} title={t('message.think')} open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} - collapsedContent={( - <> - - - {summary} - - - )} + onToggle={toggle} + collapsedContent={collapsedContent} > -
- -
+ {content}
) -} +}) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index f8f78000f1..9fa302992d 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -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): void { +export function registerChatNodeRenderers( + ctx: Context, + performanceUsage: ObservableSnapshot, + presentation: ObservableSnapshot, +): 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', diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts new file mode 100644 index 0000000000..7e66c15ca5 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -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') +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5e811b7963..4086eb4d5b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -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 @@ -30,6 +31,13 @@ export type UseChatNode = KeyedSnapshotSelectorHook +/** + * 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 + /** 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 + } +} + /** Full props of one keyed Chat renderer. */ export type ChatNodeViewProps = 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 + /** Live presentation policy derived from the accepted work-details mode. */ + presentation: ObservableSnapshot } keyedHooks: { /** Resolve the stable source for one Chat Node key. */ diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 80f8654e7c..86833382c0 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -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 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index fb91233786..8c805e6107 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts index 331840e80c..d0f6c40798 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -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 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index a49ae6031b..d337954772 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -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, diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c2fe3a5d0..6bef23a2c5 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -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, diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 51d3adca00..a0440b4682 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -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', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts new file mode 100644 index 0000000000..9221afa711 --- /dev/null +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -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> = { + 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, +): ObservableSnapshot { + return { + getSnapshot: () => POLICIES[mode.getSnapshot()], + subscribe: listener => mode.subscribe(listener), + } +} diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index 7dc7df7363..2b29abacd9 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -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 } - /** 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 -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 /** - * 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 ( ({ 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) }} /> ) diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 5534a0139c..a97e17bda8 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -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 = 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) } } diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 6229537c23..cd0c89e5c5 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -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' diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 55a02209ed..77bc597140 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -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( { it('keeps non-absolute destinations inert', () => { const { container } = render( { 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, diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 013ed04786..f389653c72 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -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( ()} /> case 'assistant-step': - return ()} /> + return ()} usePresentation={props.usePresentation} /> case 'command': return ( { 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() const contextRow = view.container.querySelector('[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() - const promptRow = view.container.querySelector('[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('[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() 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() 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() 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() const toggle = view.getByRole('button', { name: '已思考' }) const reasoning = view.container.querySelector('[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() 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() 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() 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(, { container: host }) fireEvent.click(turnProcessControl(view.container)!) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f116edab09..90c9c79b0a 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -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', () => { diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 2a2883ebb3..bc759ada20 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -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( { expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( { // groups is layout noise (no text, no pulse, no interrupted marker). const empty = render( { ) expect(empty.container.firstChild).toBeNull() const blank = render( - , + , ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 8bfb4014b6..d97056af81 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -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( { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( { const calls: MessageImagesRenderOwner[] = [] const view = render( selector(presentationPolicyFor('detailed')) diff --git a/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx new file mode 100644 index 0000000000..c7070e147c --- /dev/null +++ b/packages/client/ui-chat/tests/presentation-policy.client.spec.tsx @@ -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('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('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 {String(fold)} + } + function Preview() { + const preview = usePresentation(policy => policy.settledReasoningPreview) + previewRender(preview) + return {String(preview)} + } + render(<>) + 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) + }) +}) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index dedfa9198f..c729a91634 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -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() + 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('detailed') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) + const view = render() + 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('compact') + const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) + const props = { text: 'First line\n', usePresentation, t } + const view = render() + const summary = view.getByText('First line') + const root = view.container.querySelector('[data-variant="think"]')! + expect(root.hasAttribute('data-preview')).toBe(true) + view.rerender() + 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( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( - , + , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( - , + , ) 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( , ) 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( , ) - 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( + , + ) + expect(view.getByText('Checking boundaries')).toBeTruthy() + expect(view.queryByText('Newest reasoning tokens')).toBeNull() view.rerender( , @@ -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( { }, { 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( { .join('\n\n') + '\n\nReasoning body.' const view = render( { const first = '## Investigation\n\n**Check persistence**\n\n' const view = render( { const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n') view.rerender( { it('expanded Think drops the inline summary and renders prose without an IN card', () => { const view = render( { it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => { const view = render( { 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() 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() - 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') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 82c928ac97..6d578cb38f 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -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(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() }) }) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c164dc8535 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -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 已拒绝', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index dde0b3eec5..5e3699e8e5 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 12fe2648e3..2606bd0dd5 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3c6f62fee2..cdf816549e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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。 | diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f79b7e4720..a4fa7a2124 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -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 - ? + ? : collapsedLeading return ( @@ -95,10 +100,10 @@ export function DisclosureRow({ {leading}
)} - {title} + {title} {(keepContentWhenOpen || !open) && collapsedContent} {open && children} ) -} +}) diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css new file mode 100644 index 0000000000..bdc2de9a44 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.module.css @@ -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; + } +} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx new file mode 100644 index 0000000000..8249626435 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.tsx @@ -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 {children} +}) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 25ba54ea24..551ced88ea 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -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' diff --git a/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts new file mode 100644 index 0000000000..b416a1705f --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer-styles.client.spec.ts @@ -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;') + }) +}) diff --git a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx new file mode 100644 index 0000000000..5f6efdf805 --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx @@ -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(Run) + 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(End) + 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(Running) + expect(view.getByText('Running')).toBe(span) + expect(span.dataset.textShimmer).toBe('true') + expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px') + }) + +}) diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index d7a9b94228..ebd0910324 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -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 加载已中止', diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index d45088b225..ead382b4ac 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -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 加载已中止', diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index da55917f84..053f78b4a9 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -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() - 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) }) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 520e800a3a..3bf04ae4ac 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -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; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 3799370527..62b91189fc 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -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) => { 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) => { + const fileLinkKeyDown = useCallback((event: KeyboardEvent) => { 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). */ + <> + + {openFile !== undefined ? ( + + ) : ( + + {summaryText} + + )} + {suffix !== null && ( + {suffix} + )} + + ), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText]) + const expandedContent = useMemo(() => open ? ( +
+ {askQuestionBody !== null + ? + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : 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. */ +
+
{imageBody.label}
+ {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { + images: imageBody.images, + loadImage, + align: 'start', + })} +
{imageBody.text}
+
+ ) + : searchBody !== null + ? ( + <> + + {/* 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 && ( +
{searchBody.recovery}
+ )} + + ) + : webBody !== null + ? + : detailsBody !== null + ? + : ( + <> + {variant === 'code' && bodyText !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} + {inspect !== undefined && ( + + )} +
+ ) : 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 (
{status !== null && {status}} @@ -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). */ - <> - - {openFile !== undefined ? ( - - ) : ( - - {summaryText} - - )} - {suffix !== null && ( - {suffix} - )} - - )} + collapsedContent={collapsedContent} > -
- {askQuestionBody !== null - ? - : terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : 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. */ -
-
{imageBody.label}
- {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { - images: imageBody.images, - loadImage, - align: 'start', - })} -
{imageBody.text}
-
- ) - : searchBody !== null - ? ( - <> - - {/* 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 && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : detailsBody !== null - ? - : ( - <> - {variant === 'code' && bodyText !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
- )} - - )} - {inspect !== undefined && ( - - )} -
+ {expandedContent}
) -} +}) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index ab8acd692c..be9b547ce6 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -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. */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 7b1f8653c5..ebbe18e532 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -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 = + /** 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) => { + }, []) + const toggleFromKeyboard = useCallback((event: KeyboardEvent) => { if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() toggleExpand() - } - const businessIcon = + }, [expandable, toggleExpand]) const leading = open - ? + ? : expandable ? ( <> - {businessIcon} + {BASH_ICON} ) - : businessIcon + : BASH_ICON return (
{leading} {status !== null && {status}} - {t(model.titleKey)} + {t(model.titleKey)} - {settlementLine ?? normalSummary} + {settlementLine ?? normalSummary}
{open && ( @@ -113,7 +122,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: ) @@ -148,7 +157,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )}
) -} +}) /** Registers the standalone Bash conversation-row sample. */ export const bashToolviewSample = { diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 80615b752f..8254a70476 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -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() 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() 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() 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) }) }) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index ca8e8bde20..cee488b8b6 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -236,7 +236,7 @@ describe('SearchRow keyed card', () => { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - 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() - expect(view.getByText('Glob')).toBeTruthy() + expect(view.getByText('查找文件')).toBeTruthy() expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 6ef6cd713a..d4749831be 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -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() 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() - 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() - fireEvent.click(view.getByRole('button', { name: /Bash/ })) + fireEvent.click(view.getByRole('button', { name: /运行命令/ })) fireEvent.click(view.getByText('查看')) expect(inspect).toHaveBeenCalledTimes(1) }) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e49ab33f28..73647081a7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -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: \'\' },\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: \'\' },\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', diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index d6188dfffa..911bf72973 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -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" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index 197e8c92f5..f1942da607 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -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: diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index 9a790ba8ea..9704a35af6 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "对话" [selected] - tab "轨迹" -- button "系统提示词" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" - button "已思考" diff --git a/snapshots/web/default-workspace/ui.expected.md b/snapshots/web/default-workspace/ui.expected.md index 425343834b..7988d45c0c 100644 --- a/snapshots/web/default-workspace/ui.expected.md +++ b/snapshots/web/default-workspace/ui.expected.md @@ -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" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 50bc047607..d9c09956cf 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -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" diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index d553156708..b5060084a4 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -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" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 50bc047607..d9c09956cf 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -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" diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index d553156708..b5060084a4 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -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" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 6435e7d5bf..fc6a0cf362 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -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" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0b593347b8..1bbd83f0d9 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -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" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 425343834b..7988d45c0c 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -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" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index ffd5ea6805..7c9f533f8c 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -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 , /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: — 随机选中的包: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 59dc395da6..2d7d6a5d24 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -11,7 +11,6 @@ - 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 , /goal pause, /goal clear"' -- button "System prompt" - button "6 tool calls" - paragraph: - strong: Turn 1 / 2 @@ -28,7 +27,6 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "System prompt" - button "5 tool calls · 2 messages" - paragraph: 两个 turn 都完成了 ✅ - paragraph: diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 8e1c4db5cc..8b6f7eae5f 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -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" diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index c4bf010378..a760c82371 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -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" diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 1cbe13209e..3813c500b7 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index b30b140798..8b64580291 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index aa423c09dc..4a5dd961fe 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 67b4bf71a4..4635768121 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 0f9f0503c1..2433cb613f 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index d201eba408..5ea5441f23 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -6,14 +6,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" - group: - status: Retried model request (1/5) · {{duration}} -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 435a2aac62..a122990338 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 6518e42770..92ddaed171 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index df127b9b31..f4776040f5 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -9,11 +9,10 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- 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" - tooltip "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" - paragraph: I will read both files before answering. - button "Copy" - button "Good response" @@ -26,7 +25,6 @@ - button "Read b.txt": - text: Read - button "b.txt" -- button "System prompt" - text: Now give the final answer. {{clock}} - button "Copy" - paragraph: DONE diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 3567494649..e7e695c43c 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 71a77be2f5..602b7120f8 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -5,20 +5,17 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call · 1 message" [expanded] - button "Context injection runtime-context" -- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."' +- button "Think" - paragraph: - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"' -- button "System prompt" -- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."' +- button "Think" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 94ba2c08b5..fe52ec9268 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -5,12 +5,9 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call · 1 message" -- button "System prompt" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index c867d85c45..5238b2a252 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,15 +1,14 @@ -- button "系统提示词" - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" - button "2 次工具调用 · 1 条消息" [expanded] - button "上下文注入 runtime-context" -- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses." +- button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 - button "写入 von-neumann.svg +83 -0": - text: 写入 - button "von-neumann.svg" - text: +83 -0 -- button "思考 The file is created. Let me present it as a deliverable." +- button "思考" - button "交付文件 已交付 von-neumann.svg" - paragraph: - text: 已创建 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index ad3780520c..02fb01a3f8 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -6,18 +6,17 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants one run_code program that:" +- button "Think" - text: Failed - 'button "Code Error: code run failed (exception): AFTER_PRESENT"' - button "Present files Delivery failed missing.txt" - button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files" - button "Present files Delivered report.txt, 说明.txt" -- button "Think The program ran as intended:" +- button "Think" - paragraph: - text: "The single program ran exactly as ordered:" - code: present diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index c62860982b..9e68c8604a 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- 'button "Think The user wants me to write a single `run_code` program that:"' +- button "Think" - button "Code Run bash echo and catch missing file read" - button "Bash Echo CODE_ROUND_OK" [expanded] - text: Done workspace echo CODE_ROUND_OK @@ -20,7 +19,7 @@ - button "Inspect" - text: Failed - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"' -- button "Think The program ran successfully. Let me now reply DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 1e5e5a39cb..488e8b8029 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -6,17 +6,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, 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 use the ask_user_question tool with specific parameters. Let me do exactly that." +- button "Think" - button "Ask question 1/1 answered" [expanded] - term: Which color do you prefer? - definition: Blue Include accessibility notes - button "Inspect" -- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index dd494fb614..fa8b822467 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -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 multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "1 tool call" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 15af0b5cc7..7238215d90 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -5,11 +5,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." +- button "Think" - button "Ask question cancelled" [expanded] - paragraph: This question set was cancelled before answers were submitted. - list: diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 765e045f17..bcb7ecf753 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 96eff6022d..13925614bf 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 0d5d2ce2b1..4ca4469eed 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -8,7 +8,6 @@ - tab "Trajectory" - group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" - button "Context injection goal" - button "Context injection runtime-context" - paragraph: partial diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index fe66236496..e95a3f6fb3 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" [expanded] diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 66052f1522..3f186da354 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 07dd357aba..02e60e5f77 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 9e43a41140..fcc1477d43 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 289963776b..a77a5d3547 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -10,7 +10,6 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index e28d8df980..95b98d26cd 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 727368f383..bcebaccbe0 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- 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 "2 tool calls" [expanded] -- 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" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,7 +27,6 @@ - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md" -- text: permission preset read-only - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 2b26bde0d6..cb0bde06e7 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- 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 "2 tool calls" [expanded] -- 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" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,7 +27,6 @@ - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md" -- text: permission preset read-only - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index bd687552fd..4f6ac15000 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -8,18 +8,17 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- 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 "2 tool calls" [expanded] -- 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" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 0a26ac4c88..4f703e11d9 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -8,7 +8,6 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- 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 "2 tool calls" diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index aa014bd9e0..816b680d6e 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -5,17 +5,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" - button "Context injection skill-catalog" -- button "Think Load the requested skill." +- button "Think" - button "Skill editing-cordis-compositions" [expanded] - region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile. ## Authoring a preset Use shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods. Copy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source. Edit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance. ## Service isolation A preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm. ```yaml - id: delegation name: cordis:group group: true isolate: workflowEngine: true config: - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` ## Verify a preset Check the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" -- button "Think The skill is loaded." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 18b8b2bda0..f8b2fc5561 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -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 "Context injection runtime-context" diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 558a2cbccd..037a6f71da 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -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" [expanded] diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 9d5e8bbdc5..b1cb14742e 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -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" diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 588d7bc778..2563dc0d92 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,4 +1,3 @@ -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 958a6e5041..c4a77f1285 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -13,23 +13,20 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" [expanded] - button "Context injection runtime-context" -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" - button "Thought for a while" [expanded] -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Think" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 62af856938..5e1ccfc3fa 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -13,7 +13,6 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Thought for a while" @@ -22,9 +21,7 @@ - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" - button "Thought for a while" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 32ef6d7bd7..7c67669374 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -9,7 +9,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" - button "Context injection runtime-context" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 88cbf864a8..52872ef7db 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 97ce445aef..cd8c64c32e 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." +- button "Think" - paragraph: Reading the workspace now. - button "Bash Print alpha to stdout" - paragraph: DONE diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 0aad69b556..ce077f8d64 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." +- button "Think" - paragraph: Reading the workspace now. - button "Bash Print alpha to stdout" - paragraph: partial diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 33ff59bd2a..df291fa049 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index ea6ffa712d..19a3319879 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - button "1 tool call · 1 message" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index a843fffa55..b769848c9f 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" - button "1 tool call" [expanded] diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index da34c06fd8..cd0ee5f743 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,12 +1,11 @@ -- button "System prompt" - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- button "Think" - button "Run workflow snapshot-flow" - button "snapshot-flow 1 member Completed" -- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop." +- button "Think" - paragraph: WORKFLOW_DONE - button "Copy" - button "Good response"