diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml new file mode 100644 index 0000000000..565b158423 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.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/implemented/bug-fix/2026-08-26-stable-turn-process-order.md +2026-08-26-stable-turn-process-order.md: 7122b901c8d24abc10c0bea51dcfc7a815b7aeeb +2026-08-26-stable-turn-process-order.zh.md: 574855fd4c9f923dfc3a2a80b815ffed3b2d7422 diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md new file mode 100644 index 0000000000..7122b901c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md @@ -0,0 +1,29 @@ +# Agent Note: Stable Turn-process ordering + +Status: implemented + +English | [中文](2026-08-26-stable-turn-process-order.zh.md) + +## Problem + +Turn-process eligibility changes as Assistant output streams, becomes a final answer, or is invalidated by a Tool call, Retry, or later Step. Ordering existing Chat Nodes from that mutable range moved the initial System prompt and pre-User Context across the opening User, so one logical row appeared at different transcript positions during a Turn. + +## Decision + +Existing Chat Nodes keep one presentation order throughout a page lifetime. Their positions depend on durable anchors, node kinds, and the opening human input, never on the mutable process start or answer boundary. Dependency replay after loading older history retains an already projected System prompt's anchor. A newly projected process control may be inserted between existing rows, while completion, Retry, later Steps, pagination completion, and manual disclosure only change visibility or add new evidence. + +System prompt is independent of Turn Process: the initial prompt remains visible above the opening User and never receives process-member or process-hidden state. A later prompt first projected from a partial window retains that position when earlier request history loads. Context injection remains process content. When a Context or another potential process row has an event anchor before the opening User, Chat places it after that User from its first projection; once available, the process control occupies the stable position between the User and those rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. + +The existing [Turn-process folding decision](../feature/2026-08-14-web-turn-process-folding.md) continues to own membership, completion, persistence, focus, and pagination behavior; this note supersedes only its earlier decision to fold System prompt and to defer pre-User process ordering until a mutable range included those rows. + +## Alternatives considered + +**Keep System prompt inside Process but preserve its original position.** Rejected because a disclosure below the opening User would control content above itself, and collapsing would remove the request-wide instruction that visually frames that User message. + +**Exempt only System prompt.** Rejected because pre-User Context could still move when answer qualification changed, preserving the same class of visual discontinuity. + +**Reparent process rows under the disclosure.** Rejected because moving keyed rows across React parents remounts stateful renderers. + +## Consequences + +The stable first-Turn presentation is `System prompt → User → Process → Context and other process rows → final Assistant`. Pre-User injected Context can therefore differ from raw event order, but it uses that semantic position from its first render. Tests cover the initial state, process appearance, completion collapse, manual expansion, and content-only answer-boundary changes. diff --git a/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md new file mode 100644 index 0000000000..574855fd4c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 稳定的轮次过程排序 + +Status: implemented + +[English](2026-08-26-stable-turn-process-order.md) | 中文 + +## 问题 + +Assistant 输出流式生成、成为最终正文,或因工具调用、Retry、后续步骤而失去正文资格时,轮次过程范围会变化。若既有 Chat Node 的排序依赖这段可变范围,首轮系统提示词和位于 User 之前的上下文会跨过开场 User,导致同一个逻辑行在轮次期间出现在不同位置。 + +## 决策 + +既有 Chat Node 在同一页面生命周期内保持同一展示顺序。其位置只依赖持久锚点、节点种类和开场人工输入,不依赖可变的过程起点或正文边界。加载更早历史触发依赖重放时,已经投影出的系统提示词保留原有锚点。新投影出的过程控件可以插入既有行之间;完成状态、Retry、后续步骤、分页完成与手动展开只改变可见性或增加新证据。 + +系统提示词独立于轮次过程:初始提示词始终显示在开场 User 上方,并且不会获得过程成员或过程隐藏状态。后续提示词若首次从不完整历史窗口投影,加载更早的请求历史后仍保留该位置。上下文注入仍属于过程内容。若上下文或其它潜在过程行的事件锚点早于开场 User,Chat 从首次投影起就将其展示在该 User 之后;过程控件出现后占据 User 与这些过程行之间的稳定位置。没有开场人工输入时,控件从首次出现起就位于最早的过程候选之前。 + +现有的[轮次过程折叠决策](../feature/2026-08-14-web-turn-process-folding.zh.md)继续负责成员关系、完成状态、持久化、焦点与分页行为;本记录仅取代其中“折叠系统提示词”以及“等可变范围纳入行后才调整 User 前过程顺序”的旧决定。 + +## 曾考虑的替代方案 + +**让系统提示词继续属于 Process,但保留原位置。** 不采用:位于开场 User 下方的 disclosure 会控制自身上方的内容,而且收起后会隐藏用于界定该 User 请求的整段指令。 + +**只排除系统提示词。** 不采用:位于 User 之前的上下文仍会随正文资格变化而移动,保留了同类视觉跳动。 + +**把过程行重新挂接到 disclosure 下。** 不采用:跨 React 父节点移动 keyed 行会重挂载有状态 renderer。 + +## 后果 + +稳定的首轮展示顺序为「系统提示词 → User → Process → 上下文及其它过程行 → 最终 Assistant」。因此,位于 User 之前的注入上下文展示顺序可能不同于原始事件顺序,但从首次渲染起保持不变。测试覆盖初始状态、过程出现、完成后默认收起、手动展开与仅正文边界变化的场景。 diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml new file mode 100644 index 0000000000..81672750b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.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/implemented/feature/2026-08-14-web-turn-process-folding.md +2026-08-14-web-turn-process-folding.md: fd0015dc915a296725344cd3f4ae05124089adf6 +2026-08-14-web-turn-process-folding.zh.md: 47b1a36bd75695a2f04dfed654d45ec81d2b8bc9 diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md new file mode 100644 index 0000000000..fd0015dc91 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md @@ -0,0 +1,43 @@ +# Agent Note: Web Turn process folding + +Status: implemented + +English | [中文](2026-08-14-web-turn-process-folding.zh.md) + +## Problem + +A model Turn can expose System prompt, Context injection, reasoning, several Assistant replies, Tool calls, and Retry rows before its final answer. Keeping that whole trajectory at full height obscures the answer, while moving independent Chat Nodes under a parent disclosure would remount stateful Tool renderers and disturb chronological evidence. The compact view must hide completed process work without hiding the only evidence available while a Turn is still thinking, using a Tool, retrying, or ending without an answer. + +## Decision + +The Host-backed `ui-chat.transcriptView` preference selects `normal` or `compact` and defaults to `compact`. Normal leaves every process row visible and renders no Turn-process control. Compact applies the disclosure rules below. Switching modes changes wrapper visibility without reparenting or unmounting Chat Node renderers, and the preference remains outside the Session log. + +In Compact mode, a Turn remains fully expanded while it is open. At `turn/end`, its latest Step becomes a final-answer boundary only when it contains user-facing Assistant reply content—non-blank text, an image, or an unknown visible block—and contains no Tool-call block. The answer remains visible. Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows before that boundary form one process disclosure. System prompt, User, and steering rows remain independent and never join the group. Completed, aborted, interrupted, failed, and max-token Turns use the same terminal projection; error, max-token, and turn-tail rows remain outside the group. A closed Turn with no final answer keeps all process evidence visible. + +The Turn-scoped `turn-process` Definition derives the first model or Tool evidence, the latest Step's finalized answer boundary, reply-bearing durable Assistant-message count before that answer, and Tool-call counts from log events plus Step Location data. Shipped subagent delegation names (`subagent` and `subagent_*`) increment the subagent count instead of the ordinary Tool-call count, so the categories never overlap. Context injection remains process evidence without incrementing a summary count; System prompt is independent, stays visible, and remains before the opening User. It publishes an encoded scalar so unchanged facts retain value identity during streaming and contributes one stable control Chat Node. The Chat target positions opening User or steering input before process candidates from their first projection, then inserts the synthetic control between that input and the process rows. Without opening human input, the control stays before the earliest process candidate from its first appearance. Answer finalization, Retry, later Steps, completion, and manual expansion therefore change visibility without changing existing nodes' relative order, as specified by [stable Turn-process ordering](../bug-fix/2026-08-26-stable-turn-process-order.md). Each process member classifies itself from the Turn specification. Only the control and answer Seats subscribe to their Turn's content-revisioned Location key list and derive external process evidence plus independent-input spacing from that bounded list, so data-only updates refresh layout without rebuilding the global order. Turn status and loaded-window completeness then gate foldability: an open Turn never folds, and a partial history exposes neither the control nor hidden members. The control Node exists from the first process evidence onward but its Seat remains hidden until the closed Turn has a final answer and complete history. Once visible, it omits every zero-valued segment, uses `Thought for a while` when all three counts are zero, and places a full-width divider below the summary. + +The Chat target binds the durable transcript preference through the shared settings scope and keeps per-Turn interaction state in its session-scoped Chat store. `ChatView` renders each business Node through one stable keyed `ChatNodeSeat`; adding the process control does not reorder existing keys, and Compact mode changes the Seat wrapper's `hidden` attribute without reparenting or unmounting a Tool, Assistant, Context, or Retry renderer. The Seat passes the same process state through `ChatNodeOwnerProps`, so the final Assistant renderer hides reasoning blocks from its own Step while leaving reply blocks visible; wrapper visibility and inline reasoning therefore share one UI-state source. + +Closed process members use `hidden="until-found"`. A `beforematch` event on any member opens the shared group in supporting browsers. The Chat column applies spacing only between visible siblings because hidden-until-found members retain searchable zero-height boxes; the control's divider spans the content width, and a closed process control uses an 8px answer gap only when no independent input intervenes, while expansion restores the ordinary 16px row spacing. In Compact mode, the non-persisted session store contains only manually expanded Turn-and-answer-Step generations; absence means collapsed, and a different answer generation starts collapsed. Every eligible closed Turn therefore uses the same default regardless of whether it completed live, appeared after Load earlier, or closed while the reader was away from the tail. This can reflow content above the reader when a Turn closes or history becomes complete. An automatic collapse that would hide a focused process descendant opens the shared group instead, leaving keyboard focus in place; a manual close focuses the process control before hiding its members. If Load earlier is present, every process remains expanded and its control stays hidden; once history is complete, eligible groups immediately use the collapsed default. A fresh page load restores the durable Normal or Compact preference; per-Turn manual expansion survives only view remounts within the same page lifetime. Switching to Normal reveals every process row, while switching back to Compact reapplies the page-lifetime manual overrides over the collapsed default. + +This presentation composes with [Conversation Node assembly](../architecture/2026-08-09-client-conversation-node-assembly.md): Definitions own deterministic process facts, the Seat owns shared interaction state, and keyed renderers remain independent. The [log-ordered human transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.md) remains complete because folding changes no session event or model input. + +## Alternatives considered + +**Fold only earlier Assistant replies.** Rejected because a common `Think → Tool → answer` Turn has only one reply-bearing Step and would expose no compact control, leaving the user's requested process content at full height. + +**Keep Context injection outside the process.** Rejected because injected runtime context is part of the pre-answer trajectory rather than a new human instruction. Its own disclosure and label remain intact when the process is expanded. System prompt is kept outside because moving or hiding the request-wide instruction changes the visible frame around the opening User. + +**Reparent the whole Turn under one summary row.** Rejected because eligibility changes while the Turn runs, and moving existing Chat Nodes across React parents remounts stateful Tool views. Stable Seats provide one disclosure without moving their children. + +**Store manual expansion in Turn Location data.** Rejected because Location data is a deterministic projection of session events and has no browser-action write path. UI gestures belong to a declared, non-persisted store. + +**Reuse `DisclosureRow` and unmount closed members.** Rejected because browser find could not discover their text and reopening would reconstruct stateful renderer subtrees. + +**Fold a live answer candidate before `turn/end`.** Rejected because streamed text can still be followed by a Tool call, Retry, or later thinking Step. Waiting for the terminal boundary prevents automatic collapse–expand–collapse cycles and the resulting layout jumps. + +**Defer a newly eligible collapse while the reader is away from the tail.** Rejected because it requires transient completion tracking and deferred state, and makes identical closed Turns start in different states depending on how they entered the viewport. Closed Turns use one deterministic default; scroll anchoring preserves position, while the focus guard preserves an active interaction. + +## Consequences + +Compact mode keeps the final answer prominent even when the Turn contains only injected Context, reasoning, or Tools before it, while expansion restores every process row in original order. Normal mode preserves the complete transcript without Turn-level controls. Hidden wrappers and Markdown subtrees remain mounted, trading browser memory for stable Tool state, manual expansion across view remounts, and browser-find recovery. Ordinary cross-message selection excludes closed members only in Compact mode; users expand the group before selecting them or choose Normal. Browsers without `hidden="until-found"` and `beforematch` retain manual disclosure but cannot reveal closed process text through page search. Unit coverage pins finalized answer boundaries, Retry and interruption, shared cross-kind expansion, final-Step reasoning, mode switching, manual expansion, immediate history-completion folding, off-tail folding, focus preservation, and content-only final-page revisions; assembled browser snapshots pin running, aborted, completed, paged-history, and persisted-setting trajectories. diff --git a/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md new file mode 100644 index 0000000000..47b1a36bd7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Web 轮次过程折叠 + +Status: implemented + +[English](2026-08-14-web-turn-process-folding.md) | 中文 + +## 问题 + +一个模型轮次可能会在最终正文之前展示系统提示词、上下文注入、推理、多条 Assistant 回复、工具调用与重试行。完整展示整条轨迹会淹没正文,而把独立 Chat Node 移入一个父级 disclosure 会重挂载有状态的工具 renderer,并扰乱按时间排列的证据。紧凑视图必须收起已完成的过程工作,同时在轮次仍处于推理、使用工具、重试或没有正文便结束时,保留当下唯一可用的证据。 + +## 决策 + +由 Host 支撑的 `ui-chat.transcriptView` 偏好提供 `normal` 与 `compact` 两种模式,默认值为 `compact`。Normal 保持所有过程行可见且不渲染轮次过程控件;Compact 应用下述 disclosure 规则。切换模式只改变 wrapper 可见性,不会重新挂接或卸载 Chat Node renderer;该偏好不写入 Session log。 + +在 Compact 模式下,轮次打开期间始终完整展开。到 `turn/end` 时,只有当最近步骤包含面向用户的 Assistant 回复内容——非空文本、图片或未知可见块——并且不含工具调用块时,该步骤才成为最终正文边界。正文保持可见。边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行统一进入一条过程 disclosure。系统提示词、用户消息与 steering 消息保持独立,绝不加入过程组。已完成、已取消、已中断、失败和达到最大 token 的轮次使用同一终态投影;错误、最大 token 与 turn-tail 行留在过程组外。关闭时没有最终正文的轮次会保留全部过程证据。 + +轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。它发布编码后的标量,使未变化的事实在流式期间保持值相等,并贡献一个稳定控制 Chat Node。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。每个过程成员根据轮次规格判断自身归属。只有控制 Seat 与正文 Seat 订阅所属轮次在内容变化时更新的 Location key 列表,并在这份受限列表内推导外部过程证据和独立输入间距,因此纯数据更新会刷新布局,而不必重建全局顺序。轮次状态与已加载窗口是否完整随后共同决定能否折叠:打开中的轮次绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的轮次拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。 + +Chat target 通过共享 settings scope 绑定持久化的 transcript 偏好,并把逐轮交互状态保存在会话作用域的 Chat store 中。`ChatView` 通过稳定的 keyed `ChatNodeSeat` 直接渲染每个业务 Node;加入过程控件不会重排既有 key,Compact 模式只改变 Seat wrapper 的 `hidden` 属性,不会重新挂接或卸载工具、Assistant、上下文或重试 renderer。Seat 通过 `ChatNodeOwnerProps` 传递同一份过程状态,因此最终 Assistant renderer 会隐藏自身步骤中的推理块,同时保留回复块;wrapper 可见性与行内推理共用一个 UI 状态真源。 + +收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「轮次 + 正文步骤」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭轮次都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在轮次关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐轮手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。 + +这项展示与 [Conversation Node 组装](../architecture/2026-08-09-client-conversation-node-assembly.zh.md)共同成立:Definition 持有确定性的过程事实,Seat 持有共享交互状态,keyed renderer 保持独立。[按日志顺序投影的人工 transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md)保持完整,因为折叠不改变任何会话事件或模型输入。 + +## 曾考虑的替代方案 + +**只折叠较早的 Assistant 回复。** 不采用:常见的 `Think → Tool → 正文` 轮次只有一个回复步骤,不会出现紧凑控件,用户要求收起的过程内容仍会完整展开。 + +**把上下文注入留在过程组外。** 不采用:注入的运行时上下文属于正文之前的轨迹,并不是一条新的人类指令;展开过程后,其自身的 disclosure 与标签仍完整保留。系统提示词则留在过程组外,因为移动或隐藏整次请求使用的指令会改变开场 User 周围的可见框架。 + +**把整个轮次重新挂接到一条摘要行下。** 不采用:折叠资格会在轮次运行期间变化,而把既有 Chat Node 移过 React 父节点会重挂载有状态工具视图。稳定 Seat 可以提供一个 disclosure,同时不移动其子节点。 + +**把手动展开状态存入轮次 Location data。** 不采用:Location data 是会话事件的确定性投影,不存在浏览器动作写入路径。UI 手势属于已声明且不持久化的 store。 + +**复用 `DisclosureRow` 并卸载收起成员。** 不采用:浏览器查找无法发现其文本,重新打开也会重建有状态 renderer 子树。 + +**在 `turn/end` 前折叠实时正文候选。** 不采用:流式文本后仍可能出现工具调用、重试或后续仅推理步骤。等待终态边界可以避免自动收起—展开—再次收起及其造成的布局跳动。 + +**读者离开尾部时暂缓刚获得资格的收起。** 不采用:该方案需要瞬时完成检测与 deferred 状态,并使相同的已关闭轮次根据进入视口的路径获得不同初始状态。已关闭轮次统一使用确定性的默认值;滚动锚定负责保持位置,焦点保护负责保留正在进行的交互。 + +## 后果 + +Compact 模式会在轮次正文前只有注入上下文、推理或工具时仍突出最终正文,展开后每条过程行按原顺序恢复;Normal 模式则保留完整 transcript 且不展示轮次过程控件。隐藏的 wrapper 与 Markdown 子树保持挂载,用一定浏览器内存换取稳定工具状态、跨视图重挂载的手动展开状态与浏览器查找恢复。只有 Compact 模式下的普通跨消息选择会排除仍收起的成员;用户可以先展开过程组,或切换到 Normal。不支持 `hidden="until-found"` 与 `beforematch` 的浏览器仍可手动展开,却无法通过页内查找揭示收起的过程文本。单元覆盖固定已定稿正文边界、重试与中断、跨 kind 共享展开、最终步骤推理、模式切换、手动展开、历史补全后立即收起、离尾收起、焦点保留与最终分页的纯数据更新;组装后的浏览器快照固定运行中、已取消、已完成、分页历史与持久化设置轨迹。 diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index fc01177397..8e4a5769ef 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -18,7 +18,9 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot, +} from './support.ts' const MODE = webSnapshotMode() const TURN_COUNT = 12 @@ -315,6 +317,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`) await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1) expect(await toolRow.textContent()).toContain(spec.toolResultMarker) + await expandOwningTurnProcess(page, toolRow) const disclosure = toolRow.locator('[data-sample="bash"]') expect(await disclosure.getAttribute('aria-expanded')).toBe('false') await disclosure.click() @@ -332,9 +335,10 @@ 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']) - await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), { - timeout: 10_000, - }).toBe(1) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1) + expect(await page.locator( + '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + ).count()).toBe(0) expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) expect(sessionEvents.filter(event => ( event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 9a04a5c3d3..5e3c02a436 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -19,7 +19,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts' +import { conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const SESSION_ID = 'chat-long-interactions-e2e' @@ -278,6 +278,7 @@ describe('web e2e: long Chat interaction contract', () => { const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') + await expandOwningTurnProcess(page, call2) expect(await summary1.getAttribute('aria-expanded')).toBe('false') expect(await summary2.getAttribute('aria-expanded')).toBe('false') await summary2.focus() diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index e7b1fedfb9..2664ef0f01 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -20,7 +20,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const HISTORY_SESSION_ID = 'chat-scroll-history-e2e' @@ -355,7 +355,7 @@ async function wheelUntilVisible(page: Page, selector: string, deltaY: number): function visibleFlowAnchor(page: Page): Promise { return page.locator('[data-conversation-scroll]').evaluate((host) => { - const rows = [...host.querySelectorAll('[data-chat-anchor-key]')] + const rows = [...host.querySelectorAll('[data-chat-anchor-key]:not([hidden])')] const viewport = host.getBoundingClientRect() const composer = host.querySelector('[data-composer-seat]') const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom @@ -431,12 +431,19 @@ async function expectMarkerAboveComposer(page: Page, marker: string): Promise { await wheelToHistoryStart(page) const older = page.getByRole('button', { name: 'Load earlier', exact: true }) + const loading = page.getByRole('button', { name: 'Loading…', exact: true }) await older.waitFor({ timeout: 10_000 }) const anchor = await visibleFlowAnchor(page) const before = await loadedFlowRows(page) await older.click() - await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before) + await expect.poll(async () => ( + await loadedFlowRows(page) > before && await loading.count() === 0 + ), { timeout: 30_000 }).toBe(true) await nextPaint(page) + if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) { + expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0) + return + } await expectSameFlowTop(page, anchor) } @@ -614,6 +621,7 @@ describe('web e2e: long Chat scroll contract', () => { const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]` const liveRow = world.page.locator(liveRowSelector) + await expandOwningTurnProcess(world.page, liveRow) await wheelUntilVisible(world.page, liveRowSelector, -300) const toolAnchor = await liveRow.evaluate((row) => { const flow = row.closest('[data-chat-anchor-key]') @@ -762,6 +770,7 @@ describe('web e2e: long Chat scroll contract', () => { const lastToolRow = world.page.locator( `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`, ) + await expandOwningTurnProcess(world.page, lastToolRow) await lastToolRow.focus() await world.page.keyboard.press('End') await expectBottom(world.page) diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 5f83566065..819521e6be 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -8,10 +8,10 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url)) @@ -94,6 +94,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { // The parent run_code row wears the code variant with the model-authored // description as its summary (the presentCall contract). const codeRow = page.locator('[data-variant="code"]').first() + await expandOwningTurnProcess(page, codeRow) await codeRow.waitFor({ timeout: 10_000 }) const nest = page.locator('[data-subcalls]').first() await nest.waitFor({ timeout: 10_000 }) @@ -106,13 +107,18 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') + await expandOwningTurnProcess(page, nest) await nest.locator('[data-sample="bash"]').first().click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') }) - it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { + it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria')) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + const snapshot = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) diff --git a/apps/web/tests/composer-tab-geometry.e2e.ts b/apps/web/tests/composer-tab-geometry.e2e.ts index a4905b86e0..ee8c9a07df 100644 --- a/apps/web/tests/composer-tab-geometry.e2e.ts +++ b/apps/web/tests/composer-tab-geometry.e2e.ts @@ -130,7 +130,7 @@ function measureTab(page: Page): Promise { async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise { await page.getByRole('tab', { name: tab, exact: true }).click() if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) - else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 }) + else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]:visible').first().waitFor({ timeout: 30_000 }) // Both measurements are taken after a paint, so a rectangle read mid-transition // cannot be reported as a shift the cascade did not cause. await page.evaluate(() => new Promise((settle) => { diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 9e378902fc..84babd4b61 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -18,7 +18,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url)) @@ -162,6 +162,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { .toBeGreaterThanOrEqual(1) const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first() + await expandOwningTurnProcess(page, inspectRow) await inspectRow.waitFor({ timeout: 10_000 }) // cordis_define does NOT go through the generic row: ui-cordis registers a @@ -169,6 +170,7 @@ describe('web e2e: Cordis tools use their owned cards', () => { // title here is the CARD's ("Cordis Plugin"), and the expanded body is the // card's own two code sections rather than a generic args dump. const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first() + await expandOwningTurnProcess(page, defineRow) await defineRow.waitFor({ timeout: 10_000 }) // The whole summary row is the expand toggle (unified tool-row interaction). await defineRow.locator('[aria-expanded]').first().click() @@ -177,10 +179,12 @@ describe('web e2e: Cordis tools use their owned cards', () => { await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE) const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first() + await expandOwningTurnProcess(page, runRow) await runRow.waitFor({ timeout: 10_000 }) await expect.poll(() => runRow.textContent()).toContain('snap-') const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first() + await expandOwningTurnProcess(page, stopRow) await stopRow.waitFor({ timeout: 10_000 }) await expect.poll(() => stopRow.textContent()).toContain('snap-') await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok') 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 new file mode 100644 index 0000000000..1459a2aa8a --- /dev/null +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -0,0 +1,56 @@ +- tree "Sessions": + - treeitem "{{workspace}}" [expanded]: + - img + - text: {{workspace}} + - treeitem "Review deepseek-harness/deepseek-harness#314 Session actions for Review deepseek-harness/deepseek-harness#314" [selected]: + - text: Review deepseek-harness/deepseek-harness#314 + - button "Session actions for Review deepseek-harness/deepseek-harness#314": + - img + +--- + +- banner: + - navigation "Session hierarchy": + - button "Review deepseek-harness/deepseek-harness#314" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection webhook github webhook handled by review-pr-when-ready": + - img + - img + - text: Context injection webhook github webhook handled by review-pr-when-ready +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: "Review complete: no actionable findings." +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Read Only"': Read Only +- button "Select model, current github-webhook-review-test/reply": + - text: github-webhook-review-test/reply + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} 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 d30b4a4e1e..c4f9670888 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -24,14 +24,9 @@ - img - img - text: System prompt -- button "Context injection webhook github webhook handled by review-pr-when-ready": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection webhook github webhook handled by review-pr-when-ready -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: "Review complete: no actionable findings." - button "Copy": - img 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 7e9a1518ba..10f4e568fe 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -44,7 +44,11 @@ - img - button "Decrease font size": - img - - text: px Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - text: px Conversation display Controls process content in completed turns + - button "Compact": + - text: Compact + - img + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior - button "Queue": - text: Queue - img diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c884703ca6..1aa949a86d 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -44,7 +44,11 @@ - img - button "减小字号": - img - - text: px 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 + - text: px 对话显示 控制已完成轮次的过程内容 + - button "Compact": + - text: Compact + - img + - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送": - text: 排队发送 - img 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 new file mode 100644 index 0000000000..8a1e8287a7 --- /dev/null +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -0,0 +1,49 @@ +- banner: + - navigation "Session hierarchy": + - button "/user-invoke-demo and confirm the fixtur" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok 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 54ad367774..dea9369875 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -16,14 +16,9 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Context injection user-invoke-demo": - - img - - img - - text: Context injection user-invoke-demo - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md new file mode 100644 index 0000000000..39c0dc68da --- /dev/null +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -0,0 +1,59 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 2bff42b96c..f17b2e14c7 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -16,18 +16,9 @@ - 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": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call": + - text: 1 tool call - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. -- button "Ask question 1/1 answered": - - img - - img - - text: Ask question 1/1 answered - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index ac92f5da0c..957fabf4a3 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -15,7 +15,8 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -23,6 +24,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') const MODE = webSnapshotMode() // Discard port: loopback listener never binds, so FULL telemetry discloses // the shipped default policy without any record reaching a collector. @@ -91,12 +93,20 @@ describe('web e2e: /feedback command acknowledgement', () => { expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'ack.expected.md', 'ack-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 2d81b67653..f60e504160 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -16,7 +16,8 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -27,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-rele // manifest's `session.source`) instead of recording a duplicate. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url)) const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md') const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -115,6 +117,12 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) @@ -132,6 +140,6 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md']) }) }) diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 4d473ddf13..1530c35c80 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -11,6 +11,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-webhook' import { + captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, @@ -23,6 +24,9 @@ import { saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url)) const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url)) +const EXPANDED_EXPECTED = fileURLToPath( + new URL('./expected/github-ready-review/conversation-expanded.expected.md', import.meta.url), +) const PROVIDER = 'github-webhook-review-test' const MODEL = 'reply' const SECRET = 'github-webhook-review-secret' @@ -157,6 +161,12 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd) const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(EXPANDED_EXPECTED, `${tree}\n\n---\n\n${expanded}`, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 18596f923c..e901035fe7 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -11,7 +11,7 @@ import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-goal' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -20,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-tu const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md') const MODE = webSnapshotMode() const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的' @@ -29,7 +30,7 @@ const PACKAGE_FILES: Readonly> = { 'packages/client/ui-conversation/README.md': '# UI conversation\n', 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n', 'packages/client/ui-conversation/src/client.ts': 'export {}\n', - 'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n', + 'packages/client/ui-chat/tests/chat-view.client.spec.tsx': 'export {}\n', 'packages/context/session-reference/README.md': '# Session reference\n', 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n', 'packages/context/session-reference/src/index.ts': 'export {}\n', @@ -153,9 +154,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(goalRounds(sessionEvents)).toEqual([1, 2]) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) - await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), { - timeout: 15_000, - }).toBe(2) + 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.locator( + '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + ).count()).toBe(0) const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled')))) @@ -163,11 +166,19 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = await branchButtons.last().focus() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 140_000) it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'replay.override.json', 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index d8714e9a91..1a01d0c4f9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -18,7 +18,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' @@ -33,6 +34,7 @@ const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') +const RELOADED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded-expanded.expected.md') const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' @@ -239,6 +241,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () // must render the same settled transcript the live turn produced. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(RELOADED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) }, 90_000) @@ -277,7 +285,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', + 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 5fd28cd394..53c84ba989 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -23,7 +23,8 @@ import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -34,10 +35,12 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // state, and the other four capture what remains after cancel, after a // non-retryable failure, after retry recovery, and after retry exhaustion. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') +const CANCEL_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'cancel-expanded.expected.md') const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') +const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md') const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md') const MODE = webSnapshotMode() const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid' @@ -185,6 +188,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // partial ('partial' is the hang entry's replayed prefix) and no more. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(CANCEL_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -268,6 +277,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // while the settled retry row remains as durable recovery context. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold!.workspaceCwd, + ) + await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) @@ -307,8 +322,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md', - 'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md', + 'session.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md', + 'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md', + 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md', ]) }) }) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index ececcbd5bf..b63e155d9d 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -154,6 +154,12 @@ describe('minimal agent preset', () => { await sessionRow.click() await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 }) + const process = page.locator('[data-turn-process]') + await process.waitFor({ timeout: 15_000 }) + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false') + await process.click() + await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true') + const row = page.locator('[data-sample="bash"]').first() await row.waitFor({ timeout: 15_000 }) await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index ffc1084d9e..5d734d93b8 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -19,7 +19,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.jsonl') @@ -390,6 +390,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) await ensureSeedOpen(page) const bashRow = page.locator('[data-sample="bash"]').first() + await expandOwningTurnProcess(page, bashRow) await bashRow.waitFor({ timeout: 15_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') @@ -425,6 +426,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Expanded, the recorded command's own output sits in the message flow, // derived from the logged call/result presentations alone. const bashRow = page.locator('[data-sample="bash"]').first() + await expandOwningTurnProcess(page, bashRow) await bashRow.waitFor({ timeout: 15_000 }) if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 6e1f410fba..3258604b35 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -15,7 +15,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -27,6 +28,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md') const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md') const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md') +const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md') const MODE = webSnapshotMode() // One command line: /plan enters plan mode and submits the rest as the turn's @@ -111,13 +113,20 @@ describe('web e2e: plan review takeover round trip', () => { await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(APPROVED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md', + 'session.jsonl', 'review.expected.md', 'sidebar.expected.md', + 'approved.expected.md', 'approved-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d0956666b0..29fbf1e146 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -19,7 +19,9 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -30,6 +32,7 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md') +const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md') const MODE = webSnapshotMode() const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e' @@ -289,16 +292,20 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator('[data-question-key]').count()).toBe(0) expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) - // Golden of the answered transcript: the ask_user_question round trip - // rendered as history (expanded readable answers + DONE), composer takeover gone. + // The default golden pins Compact mode before process disclosure. + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) + // Keep the ask_user_question card's readable answer in the expanded golden + // even though Compact mode hides the process by default. + await expandTurnProcesses(page) const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) await answeredRow.click() await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1) expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1) expect(await page.getByText(/"answers"/).count()).toBe(0) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) + const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) @@ -410,6 +417,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () 'composed.expected.md', 'answered.expected.md', 'cancelled.expected.md', + 'answered-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 637b542084..c20e1e396f 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest' import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +24,7 @@ const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md') +const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -168,6 +169,12 @@ describe('web e2e: queue row actions', () => { const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PRESERVED_EXPANDED_EXPECTED, expanded, MODE) const settled = scaffold.whenTurnSettled() await input.fill(WAKE) @@ -272,7 +279,10 @@ describe('web e2e: queue row actions', () => { it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'], + [ + 'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', + 'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md', + ], ) }) }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 44a81930d6..709fb83fca 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -17,15 +17,21 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' +import { + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const UI_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), +) const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -163,10 +169,17 @@ describe('web e2e: fresh round trip through the real assembly', () => { }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) - it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => { + it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', 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) @@ -190,6 +203,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { // tier pins the same gesture against FixtureApiClient; this one runs on // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. + await expandTurnProcesses(page) const think = page.getByRole('button', { name: /^Think/ }).first() expect(await think.getAttribute('aria-expanded')).toBe('false') await think.click() @@ -208,6 +222,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { 'tool-schemas.expected.json', 'web-context.expected.md', 'ui.expected.md', + 'ui-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 54e75623c6..b664348c25 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1169,6 +1169,39 @@ export async function captureStableAria( return previous } +/** + * Capture a stable aria snapshot with every eligible Turn process expanded, + * then restore the controls that were closed before the capture. + * @param page - the page under test. + * @param selector - the region locator selector. + * @param workspaceCwd - normalization input. + * @returns the stable normalized expanded snapshot. + */ +export async function captureExpandedTurnProcessAria( + page: Page, + selector: string, + workspaceCwd: string, +): Promise { + const controls = page.locator('[data-turn-process]') + const count = await controls.count() + expect(count).toBeGreaterThan(0) + const opened: number[] = [] + for (let index = 0; index < count; index++) { + const control = controls.nth(index) + if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue + await control.click() + opened.push(index) + } + try { + return await captureStableAria(page, selector, workspaceCwd) + } finally { + for (const index of opened.reverse()) { + const control = controls.nth(index) + if (await control.getAttribute('aria-expanded') === 'true') await control.click() + } + } +} + /** * Compare a normalized golden, or rewrite it under refresh. Refresh is the * ONLY writer: a missing golden in replay mode fails with the healing command diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 3349eb17b5..44d6a10254 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -22,15 +22,19 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeter } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url)) +const UI_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url), +) // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url)) @@ -278,6 +282,14 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { timeout: 10_000, }).toBe(1) + const process = page.locator('[data-turn-process="1"]') + await process.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom) + const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => + element.getBoundingClientRect().top) + // Collapsed control row keeps its own 8px margin plus the 8px flow gap. + expect(answerTop).toBe(processBottom + 16) expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0) // Tool cards render from logged tool/call + tool/result alone (views are // host-recomputed per page; the generic card is the documented default). @@ -331,6 +343,12 @@ describe('web e2e: seeded history renders through cold resume', () => { const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = (await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + )).split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { @@ -395,6 +413,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // file links (not expand-in-place / not details). Runs after the golden // capture; still zero model calls. const fileLink = page.locator('[data-variant="read"] button').first() + await expandOwningTurnProcess(page, fileLink) await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') @@ -552,6 +571,9 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'session.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', + 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md', + ]) }) }) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 425ff5d1ee..695dc36d4e 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -408,6 +408,36 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('persists the completed-Turn transcript mode across reload', async () => { + 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: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + await dialog.getByRole('button', { name: 'Normal', 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/) + await page.keyboard.press('Escape') + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const reloaded = page.getByRole('dialog', { name: '设置' }) + await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 }) + + await reloaded.getByRole('button', { name: 'Normal', exact: true }).click() + await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() + await reloaded.getByRole('button', { name: 'Compact', 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') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('persists the busy-state Enter behavior across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 25914d5fa0..9658d3692c 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -10,7 +10,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) @@ -42,7 +42,9 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() - await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 }) + const skillRow = page.locator('[data-tool="skill"]') + await expandOwningTurnProcess(page, skillRow) + await skillRow.waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 1248d12df9..e9b6891b7b 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { assertFixtureInventory, + captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, @@ -21,10 +22,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md') const MODE = webSnapshotMode() const SKILL_NAME = 'user-invoke-demo' @@ -120,10 +122,16 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) // The rendered body arrives as a context-injection row named after the - // skill; expanding it reveals the canonical block, and - // the user's text is NOT folded into it. + // skill. Context plus the final answer contributes no summary count, so + // the Turn uses the fallback title while the row's own disclosure remains usable. + const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME }) + await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 }) + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + await settled + const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) + await process.waitFor({ state: 'visible', timeout: 10_000 }) + await expandOwningTurnProcess(page, injectionFlow) const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) - await injectionRow.waitFor({ timeout: 15_000 }) await injectionRow.click() const injectionBody = page .locator('[data-context-injection-body]') @@ -133,18 +141,21 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro expect(injected).toContain('Reply with the fixture acknowledgement line.') expect(injected).not.toContain(ARGS_TEXT) await injectionRow.click() - - // The injection started a turn; the replay adapter answers it. - await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) - await settled + await process.click() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) it('keeps its snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md']) }) }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 41f42aaf31..87981a5713 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -11,7 +11,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -24,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') // from user/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md') const MODE = webSnapshotMode() // The question composer replaces the textarea, so fill → Queue row → Steer // starts only after request/context and must finish before the first replay @@ -43,6 +45,7 @@ const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ALL_SETTLED_EXPANDED = join(STEER_ALL_DIR, 'settled-expanded.expected.md') const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' @@ -171,12 +174,20 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // obeying reply, composer takeover gone. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md', + ]) }) }) @@ -392,13 +403,20 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { expect(await page.locator('[data-pending-steering]').count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(STEER_ALL_SETTLED_EXPANDED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(STEER_ALL_DIR, [ - 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + 'replay.override.json', 'mid-steer.expected.md', + 'settled.expected.md', 'settled-expanded.expected.md', ]) }) }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 72df81a2f3..7b792528e7 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -11,7 +11,8 @@ import { import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { - acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, + compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' @@ -19,6 +20,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url)) +const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath( + new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url), +) const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url)) @@ -243,7 +247,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /subagents/ }) + const catalogButton = page.getByRole('button', { name: '3 subagents', exact: true }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.hover() const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) @@ -434,6 +438,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria')) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE) + const expanded = await captureExpandedTurnProcessAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(AVAILABLE_CHILD_EXPANDED_EXPECTED, expanded, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 4bf8c7f1bc..63cf8c3ff8 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) @@ -31,6 +31,35 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise

{ + const controls = page.locator('[data-turn-process]') + await controls.first().waitFor({ state: 'visible', timeout: 10_000 }) + const count = await controls.count() + for (let index = 0; index < count; index++) { + const control = controls.nth(index) + if (await control.getAttribute('aria-expanded') !== 'true') await control.click() + } +} + +/** + * Expand the Turn-process group containing one possibly hidden descendant. + * @param page - page containing the Chat view. + * @param target - descendant whose owning Turn process should open. + */ +export async function expandOwningTurnProcess(page: Page, target: Locator): Promise { + const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn) + if (turn === undefined || await target.isVisible()) return + const control = page.locator(`[data-turn-process="${turn}"]`) + await control.waitFor({ state: 'visible', timeout: 10_000 }) + if (await control.getAttribute('aria-expanded') !== 'true') await control.click() +} + /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ export function requireDist(): void { if (!existsSync(DIST_INDEX)) { diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 9a78fb9f9c..c207d9278b 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -24,10 +24,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// Two goldens for the same message: parked mid-turn, then settled. +// Three goldens for the same message: parked mid-turn, aborted, and completed. const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md') +const COMPLETED_EXPECTED = join(SNAPSHOT_DIR, 'completed.expected.md') +const FOCUSED_EXPECTED = join(SNAPSHOT_DIR, 'focused.expected.md') const MODE = webSnapshotMode() // The recording must carry text in the SAME assistant message as the tool @@ -60,7 +62,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { }) /** Boot scaffold + page, materializing the sidecar before the replay row installs. */ - async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise { + async function launch( + buildOverride?: (sidecarHome: string) => ReplayOverrideDoc, + paceMs?: number, + ): Promise { sessionEvents = [] let overridePath: string | undefined if (buildOverride !== undefined) { @@ -75,6 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { replayFixture: FIXTURE, ...(overridePath === undefined ? {} : { replayOverride: overridePath }), compareReplaySession: overridePath === undefined, + ...(paceMs === undefined ? {} : { paceMs }), }, ) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) @@ -122,13 +128,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions')) // The barrier is armed before the park and awaited only after the stop // click, so its budget must cover the whole parked phase: marker poll, - // three UI polls, and two captures with their stability windows. The + // live-state polls, and two captures with their stability windows. The // replay default (30s) leaves no headroom on a slow runner. const { settled } = await sendPrompt(120_000) // The marker IS the synchronization: the second call is provably parked, // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) - await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await page.locator('[data-turn-process]').count()).toBe(0) await expect.poll( () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), { timeout: 10_000 }, @@ -148,6 +154,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted']) + await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) await copyButtons.last().focus() @@ -182,9 +189,89 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) + it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed')) + const { settled } = await sendPrompt() + await settled + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const process = page.locator('[data-turn-process]') + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px') + const processBottom = await process.evaluate(element => + element.closest('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom) + const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element => + element.closest('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top) + expect(answerTop).toBe((processBottom ?? 0) + 8) + await process.focus() + const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => { + await launch() + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) + const { settled } = await sendPrompt() + await settled + const process = page.locator('[data-turn-process]') + const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' }) + await process.waitFor({ timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + + 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 page.keyboard.press('Escape') + + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0) + await tool.waitFor({ state: 'visible', timeout: 10_000 }) + await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-chat:\n\s+transcriptView: normal/) + + 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 page.getByRole('menuitem', { name: 'Compact', exact: true }).click() + await page.keyboard.press('Escape') + await process.waitFor({ timeout: 10_000 }) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps a focused process member open when the completed reply arrives', async () => { + await launch(undefined, 200) + onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused')) + const { settled } = await sendPrompt() + const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' }) + await tool.waitFor({ timeout: 30_000 }) + await tool.focus() + expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true) + await settled + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const process = page.locator('[data-turn-process]') + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('true') + expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true) + const focused = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(FOCUSED_EXPECTED, focused, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, [ - 'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md', - ]) + await assertFixtureInventory( + SNAPSHOT_DIR, + [ + 'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.jsonl', + 'settled.expected.md', 'usage-expanded.expected.md', + ], + ) }) }) diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index bba79e3ad4..5f9e118767 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.jsonl', import.meta.url)) @@ -263,7 +263,9 @@ describe('web e2e: shipped default web search', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria')) await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 }) .toBeGreaterThanOrEqual(1) - await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 }) + const searchTool = page.locator('[data-tool="web_search"]') + await expandOwningTurnProcess(page, searchTool) + await searchTool.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) @@ -271,6 +273,7 @@ describe('web e2e: shipped default web search', () => { it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll')) const row = page.locator('[data-tool="web_search"] [data-expandable]').first() + await expandOwningTurnProcess(page, row) await row.click() await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') @@ -299,6 +302,7 @@ describe('web e2e: shipped default web search', () => { it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room')) + await expandOwningTurnProcess(page, page.locator('[data-tool="web_search"]')) // `overflow-y: auto` clips inline-start overflow with no way to scroll it // back, and markers are right-aligned to the content edge, so a marker wider // than `padding-left` silently loses its leading digits. `searchMaxResults` diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index ede39f8e0f..da846b1c6b 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -15,7 +15,7 @@ import { type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot, + connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, } from './support.ts' const MODE = webSnapshotMode() @@ -161,6 +161,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() await settled + await expandTurnProcesses(page) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) @@ -186,6 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history')) await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expandTurnProcesses(page) const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) await workflow.waitFor({ timeout: 15_000 }) expect(await workflow.getAttribute('aria-expanded')).toBe('false') diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index bcb0fe6b05..c69c9d86b9 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: c54901d6951908eaef728b16d295c1f749e7fe22 -module-graph.zh.md: 67e8c8227035f0bbe411c557aba7ce3fe7b60e03 +module-graph.md: a6f70aa1ff1616acf305ae386b148c56d8ebcf85 +module-graph.zh.md: 3b33bc68e67c19c03ec4d212c864371b27b31278 diff --git a/docs/module-graph.md b/docs/module-graph.md index c54901d695..a6f70aa1ff 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1529,6 +1529,7 @@ flowchart TD pkg_client_ui_chat --> pkg_client_ui_layout pkg_client_ui_chat --> pkg_client_ui_renderer pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_settings pkg_client_ui_chat --> pkg_client_ui_workspace pkg_client_ui_chat --> pkg_commands pkg_client_ui_chat --> pkg_compaction @@ -1537,6 +1538,7 @@ flowchart TD pkg_client_ui_chat --> pkg_llm_retry pkg_client_ui_chat --> pkg_session pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools pkg_client_ui_chat --> pkg_util_workspace_path @@ -1931,7 +1933,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 67e8c82270..3b33bc68e6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1531,6 +1531,7 @@ flowchart TD pkg_client_ui_chat --> pkg_client_ui_layout pkg_client_ui_chat --> pkg_client_ui_renderer pkg_client_ui_chat --> pkg_client_ui_session + pkg_client_ui_chat --> pkg_client_ui_settings pkg_client_ui_chat --> pkg_client_ui_workspace pkg_client_ui_chat --> pkg_commands pkg_client_ui_chat --> pkg_compaction @@ -1539,6 +1540,7 @@ flowchart TD pkg_client_ui_chat --> pkg_llm_retry pkg_client_ui_chat --> pkg_session pkg_client_ui_chat --> pkg_session_stats + pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools pkg_client_ui_chat --> pkg_util_workspace_path @@ -1933,7 +1935,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5919181d67..a38a72ef3e 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: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff -README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77 +README.md: 5c7549da07e8101c6b08b4cad57afd5158980b7f +README.zh.md: a558bf94f5436eebdf035ca7507ecd25ec3c8d8a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index cd69eb7fdf..5c7549da07 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -14,6 +14,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin - [System prompt row](#system-prompt-row) - [Turn token usage](#turn-token-usage) +- [Turn Process Folding](#turn-process-folding) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -34,6 +35,13 @@ A completed Turn shows an expandable usage row only when the loaded window inclu ----- + +## Turn Process Folding + +Settings → General exposes a persisted `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 ([folding decision](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md), [ordering decision](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md)). + +----- + ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3e9d1ccab7..a558bf94f5 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -14,6 +14,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition - [系统提示词行](#system-prompt-row) - [轮次 token 用量](#turn-token-usage) +- [轮次过程折叠](#turn-process-folding) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -34,6 +35,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真 ----- + +## 轮次过程折叠 + +「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的 `Normal` / `Compact` 对话显示偏好,默认使用 `Compact`。Normal 保持所有过程行可见且不渲染轮次过程控件。Compact 模式下,系统提示词在整个轮次中始终独立显示于开场 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 高度变化。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域 store 只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起([折叠决策](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md),[排序决策](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md))。 + +----- + ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index b29c675a08..10382a63bf 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-workspace" ], "platform": "web" @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", @@ -70,6 +72,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" @@ -89,6 +92,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", @@ -98,12 +102,16 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts new file mode 100644 index 0000000000..00433288f9 --- /dev/null +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -0,0 +1,29 @@ +/** Chat transcript preferences stored in the Host user-settings document. */ + +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. */ +export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' + +/** Transcript presentation modes accepted at settings boundaries. */ +export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const + +/** Completed-Turn transcript presentation. */ +export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] + +/** Default preserves the compact process disclosure introduced by Chat. */ +export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact' + +/** Durable Chat section shared by the Host schema and browser scope. */ +export interface ChatSettings { + /** Presentation mode for completed Turn process content. */ + transcriptView: TranscriptViewMode +} + +/** Durable Chat schema; also the wire envelope the browser scope validates against. */ +export const ChatSettingsSchema: z = z.object({ + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).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 1e534dd697..1fc602e1ac 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected, @@ -25,7 +26,10 @@ import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { DetailsPanel } from './details/DetailsPanel.tsx' 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 { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { hooks: { @@ -42,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope', ] /** @@ -74,6 +78,20 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(NS) const chatStore = createChatStore() const chatScrollPositions = new Map() + const transcriptView = new TranscriptViewPolicy( + ctx.settingsScope.bind({ namespace: CHAT_SETTINGS_NAMESPACE }), + ) + + ctx.slots.inject('settings.general.item', () => ctx.slots.register({ + name: 'settings.general.item', + id: 'transcript-view', + order: 12, + locale: NS, + inject: (): TranscriptViewRowInjected => ({ + hooks: { transcriptView: transcriptView.mode }, + setTranscriptView: (mode) => { transcriptView.setMode(mode) }, + }), + }, TranscriptViewRow)) ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ @@ -91,6 +109,7 @@ export function apply(ctx: Context): void { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) return { + hooks: { transcriptView: transcriptView.mode }, openDetails: (target) => { actions.select(target) ctx.layout.openDetails() diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 8c1858fe94..8d7c41ac85 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -42,6 +42,13 @@ padding-left: var(--dsh-table-lead); } +/* hidden="until-found" keeps a zero-height reasoning box in flex layout. + Cancel the one gap that box would otherwise leave before the visible reply; + visible Assistant blocks retain the ordinary 16px rhythm. */ +.body > [data-turn-process-inline][hidden] { + margin-bottom: -16px; +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed size like the small/code token variants — 11px is dense secondary text that would fall to an illegible 9px at the 12px floor. */ diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 8838725f75..c517f9d0c2 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -6,6 +6,7 @@ import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' +import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { @@ -15,6 +16,10 @@ 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. */ + reasoningHidden?: boolean | undefined + /** Reveal the owning Turn-level process disclosure. */ + revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -23,7 +28,8 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, mentions, t, + blocks, streaming, interrupted, renderMessageImages, + reasoningHidden = false, 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. @@ -53,7 +59,15 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) break case 'reasoning': - rendered.push() + rendered.push( + , + ) break case 'image': { // Consecutive image blocks share one gallery so several images tile @@ -102,3 +116,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ ) }) + +function ProcessReasoning({ hidden, reveal, children }: { + hidden: boolean + reveal?: (() => void) | undefined + children: ReactNode +}) { + const ref = useSearchableHidden(hidden, reveal ?? NOOP) + return

{children}
+} + +const NOOP = (): void => {} diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 850036f0cd..399897a9b5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -1,10 +1,10 @@ -import { memo, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, openFile, renderMessageImages, fileMentions, t, + node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -20,12 +20,20 @@ export const AssistantNodeView = memo(function AssistantNodeView({ () => owner === undefined ? undefined : fileMentions(owner), [fileMentions, owner], ) + const reasoningHidden = turnProcess !== undefined + && turnProcess.foldable + && turnProcess.spec.answerStep === data.step + && turnProcess.spec.inlineReasoning + && !turnProcess.open + const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess]) return ( diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 7568f767ff..00eb9fd70d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,12 +1,23 @@ -import { memo, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore } from '../contract/snapshot.ts' +import { + decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration, + type TurnProcessSpec, +} from '../contract/turn-process.ts' +import { storedTurnProcessEntry } from '../stores.ts' +import { useSearchableHidden } from './searchable-hidden.ts' import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string + readonly historyIncomplete: boolean + readonly compactTranscript: boolean readonly useChat: ChatViewSlotProps['useChat'] + readonly useStore: ChatViewSlotProps['useStore'] + readonly actions: ChatViewSlotProps['actions'] readonly renderSlot: ChatViewSlotProps['renderSlot'] readonly t: ChatViewSlotProps['t'] } @@ -15,13 +26,153 @@ type RoutedChatNodeOwner = { [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode } }[ChatNode['kind']] -/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +const EMPTY_PROCESS_KEYS: readonly string[] = [] + +interface TurnProcessLayout { + readonly hasExternalProcess: boolean + readonly compactAnswer: boolean +} + +function turnProcessOpeningHumanAnchor( + keys: readonly string[], + nodes: ChatNodeStore, + spec: TurnProcessSpec, +): number | undefined { + let anchor: number | undefined + for (const key of keys) { + const node = nodes.get(key) as ChatNode | undefined + if ((node?.kind === 'user' || node?.kind === 'steering') + && node.anchorSeq < spec.controlAnchorSeq) { + anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq) + } + } + return anchor +} + +/** Derive disclosure facts from one content-revisioned Turn index. */ +function turnProcessLayout( + keys: readonly string[], + nodes: ChatNodeStore, + spec: TurnProcessSpec, +): TurnProcessLayout { + let hasExternalProcess = false + let compactAnswer = true + const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec) + for (const key of keys) { + const node = nodes.get(key) as ChatNode | undefined + if (node === undefined || node.kind === 'turn-process') continue + if ((node.kind === 'user' || node.kind === 'steering') + && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor) + && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { + compactAnswer = false + } + if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + || node.anchorSeq < spec.processStartSeq + || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue + if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { + hasExternalProcess = true + } + } + return { hasExternalProcess, compactAnswer } +} + +/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useChat, renderSlot, t, + nodeKey, historyIncomplete, compactTranscript, + selectedCallId, cwd, openFile, inspectCall, forkAt, + renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) + const processSignature = useChat((snapshot) => { + const current = snapshot.nodes.get(nodeKey) + const location = current?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get('turn-process') + : undefined + }) + const processSpec = useMemo( + () => processSignature === undefined ? undefined : decodeTurnProcess(processSignature), + [processSignature], + ) + const nodeStore = useChat(snapshot => snapshot.nodes) + const processLayoutKeys = useChat((snapshot) => { + if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS + const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined + const location = current?.location + if (current === undefined + || (location?.kind !== 'turn' && location?.kind !== 'step') + || location.turn.status !== 'closed' + || location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS + const ownsLayout = current.kind === 'turn-process' + || (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep) + return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS + }) + const processLayout = useMemo( + () => processSpec === undefined || processLayoutKeys.length === 0 + ? undefined + : turnProcessLayout(processLayoutKeys, nodeStore, processSpec), + [nodeStore, processLayoutKeys, processSpec], + ) + const processGeneration = useMemo( + () => processSpec === undefined ? undefined : turnProcessGeneration(processSpec), + [processSpec], + ) + const storedEntry = useStore(state => processSpec === undefined + ? undefined + : storedTurnProcessEntry(state, processSpec.turn)) + const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined + const processOpen = processEntry !== undefined + const setOpen = useCallback((open: boolean) => { + if (processGeneration !== undefined && processSpec !== undefined) { + actions.setTurnProcessOpen(processSpec.turn, processGeneration, open) + } + }, [actions, processGeneration, processSpec]) const routedNode = node as ChatNode | undefined + const sameTurn = routedNode !== undefined + && processSpec !== undefined + && (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step') + && routedNode.location.turn.turn === processSpec.turn + const turnClosed = sameTurn + && routedNode.location.turn.status === 'closed' + const processWindowReady = processSpec !== undefined + && compactTranscript + && processSpec.answerAnchorSeq !== null + && turnClosed + && !historyIncomplete + const processMember = sameTurn + && processWindowReady + && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) + && routedNode.anchorSeq >= processSpec.processStartSeq + && routedNode.anchorSeq < processSpec.answerAnchorSeq + const processAnswer = sameTurn + && processWindowReady + && routedNode.kind === 'assistant-step' + && routedNode.data.step === processSpec.answerStep + const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer + const foldable = processWindowReady + && (processMember || (ownsDisclosure + && ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning))) + const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined + ? undefined + : { + spec: processSpec, + foldable, + open: processOpen, + setOpen, + }, [ + foldable, processGeneration, processOpen, processSpec, setOpen, + ]) + const controllerInactive = routedNode?.kind === 'turn-process' + && !foldable + const compactAnswer = processAnswer + && foldable + && processLayout?.compactAnswer === true + && !processOpen + const processHidden = controllerInactive || (foldable && processMember && !processOpen) + const revealProcess = useCallback(() => { + if (processMember) setOpen(true) + }, [processMember, setOpen]) + const wrapperRef = useSearchableHidden(processHidden, revealProcess) const owner = useMemo(() => node === undefined ? null : { @@ -32,8 +183,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ forkAt, renderMessageImages, fileMentions, + turnProcess, }, [ - node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, + node, selectedCallId, cwd, openFile, inspectCall, forkAt, + renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const location = routedNode.location @@ -46,11 +199,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner return (
{renderSlot('conversation.chat.node', routedOwner, { entryKey: routedNode.kind, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 502ae295be..0dbb71f541 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -41,7 +41,14 @@ margin: 0 auto; display: flex; flex-direction: column; - gap: 16px; +} + +/* `hidden="until-found"` retains a zero-height box so browser find can reveal + its subtree. Direct Chat Node Seats and auxiliary rows share one flow; + hidden and empty Seats do not contribute spacing. */ +.column > :not([hidden]):not(.flowItem:empty) + ~ :not([hidden]):not(.flowItem:empty) { + margin-top: var(--dsh-chat-flow-gap, 16px); } /* Settled-flow identity boundary. It is neutral until it becomes the natural @@ -50,6 +57,12 @@ min-width: 0; } +/* A closed process reads as one summary immediately followed by its answer. + Expanded process rows return to the ordinary 16px rhythm. */ +.flowItem[data-turn-process-answer] { + --dsh-chat-flow-gap: 8px; +} + /* A keyed renderer may intentionally decline its row after dispatch (the completed-turn tail does this when it owns neither actions nor extensions). An empty flex item must not consume the column gap. */ diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 7b370666f9..b5f0313f15 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ interface PagingAnchor { /** Find an already-rendered row without interpolating a selector. */ function anchorElement(list: HTMLElement, key: string): HTMLElement | null { - for (const row of list.querySelectorAll('[data-chat-anchor-key]')) { + for (const row of list.querySelectorAll('[data-chat-anchor-key]:not([hidden])')) { if (row.dataset.chatAnchorKey === key) return row } return null @@ -87,7 +87,9 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | if (row !== null && list.contains(row)) return row } } - const rows = list.querySelectorAll('[data-chat-flow] > [data-chat-flow-key]:not(:empty)') + const rows = list.querySelectorAll( + '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])', + ) let low = 0 let high = rows.length while (low < high) { @@ -154,7 +156,7 @@ function observedRpcIds( function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { - if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time + if (turn.status === 'open') latest = turn.start?.time ?? null } return latest } @@ -200,8 +202,8 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, - fileMentions, t, + useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, + fileMentions, useTranscriptView, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) @@ -219,6 +221,7 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const compactTranscript = useTranscriptView(mode => mode === 'compact') const inspectCall = useCallback((callId: string) => { openView('trajectory', callId) }, [openView]) @@ -278,8 +281,10 @@ export function ChatView({ const listRef = useRef(null) const columnRef = useRef(null) - const atBottomRef = useRef(true) - const [atBottom, setAtBottom] = useState(true) + // A saved position starts disarmed; the first layout effect synchronously + // restores it and normalizes a floor-clamped position back to following. + const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null) + const atBottomRef = useRef(atBottom) const [activeTurn, setActiveTurn] = useState( () => turnNavigationItems.at(-1)?.turn ?? null, ) @@ -591,7 +596,11 @@ export function ChatView({ ) { + if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state') + if (!turnProcess.foldable) return null + const open = turnProcess.open + const labels: string[] = [] + if (node.data.toolCallCount > 0) { + labels.push(t( + node.data.toolCallCount === 1 + ? 'message.turnProcess.toolCalls.one' + : 'message.turnProcess.toolCalls.other', + { count: node.data.toolCallCount }, + )) + } + if (node.data.messageCount > 0) { + labels.push(t( + node.data.messageCount === 1 + ? 'message.turnProcess.messages.one' + : 'message.turnProcess.messages.other', + { count: node.data.messageCount }, + )) + } + if (node.data.subagentCount > 0) { + labels.push(t( + node.data.subagentCount === 1 + ? 'message.turnProcess.subagents.one' + : 'message.turnProcess.subagents.other', + { count: node.data.subagentCount }, + )) + } + const label = labels.length === 0 + ? t('message.turnProcess.thoughtForAWhile') + : labels.join(t('message.turnProcess.separator')) + return ( + + ) +}) 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 748d75a364..e58ec4a9ad 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 @@ -6,8 +6,9 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' -import { TurnTailNodeView } from './TurnTailNodeView.tsx' import { SystemPromptNodeView } from './SystemPromptRow.tsx' +import { TurnProcessNodeView } from './TurnProcessNodeView.tsx' +import { TurnTailNodeView } from './TurnTailNodeView.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. @@ -40,6 +41,8 @@ export function registerChatNodeRenderers(ctx: Context): void { { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-process', locale: NS }, TurnProcessNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'turn-tail', diff --git a/packages/client/ui-chat/src/client/chat/searchable-hidden.ts b/packages/client/ui-chat/src/client/chat/searchable-hidden.ts new file mode 100644 index 0000000000..5a0f47c82e --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/searchable-hidden.ts @@ -0,0 +1,31 @@ +import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react' + +/** + * Apply searchable hidden state without unmounting a stable subtree. + * @param hidden - whether the subtree is currently hidden. + * @param reveal - callback for browser find's `beforematch` reveal. + * @returns ref for the stable subtree root. + */ +export function useSearchableHidden( + hidden: boolean, + reveal: () => void, +): RefObject { + const ref = useRef(null) + useLayoutEffect(() => { + const element = ref.current + if (element === null) return + if (hidden && element.contains(element.ownerDocument.activeElement)) { + reveal() + return + } + if (hidden) element.setAttribute('hidden', 'until-found') + else element.removeAttribute('hidden') + }, [hidden, reveal]) + useEffect(() => { + const element = ref.current + if (element === null) return + element.addEventListener('beforematch', reveal) + return () => { element.removeEventListener('beforematch', reveal) } + }, [reveal]) + return ref +} diff --git a/packages/client/ui-chat/src/client/contract/assistant-content.ts b/packages/client/ui-chat/src/client/contract/assistant-content.ts new file mode 100644 index 0000000000..e06c025873 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/assistant-content.ts @@ -0,0 +1,15 @@ +import type { AssistantBlock } from './snapshot.ts' + +/** + * Test whether Assistant blocks contain a user-facing reply rather than only + * reasoning or Tool-call protocol material. + * @param blocks - Assistant content blocks. + * @returns whether the blocks contain visible reply content. + */ +export function hasAssistantReplyContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'reasoning' || block.kind === 'tool-call') return false + if (block.kind === 'text') return block.text.trim() !== '' + return true + }) +} diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index db6f61433f..d04bdfd90e 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -97,6 +97,19 @@ export interface TurnTailChatData { readonly tokenUsage?: TurnTokenUsage } +/** Turn-level process disclosure projected before the finalized answer. */ +export interface TurnProcessChatData { + readonly turn: number + readonly controlAnchorSeq: number + readonly processStartSeq: number + readonly answerAnchorSeq: number | null + readonly answerStep: number | null + readonly inlineReasoning: boolean + readonly messageCount: number + readonly toolCallCount: number + readonly subagentCount: number +} + /** * Test whether a Tool root has settled. * @param block - Tool root lifecycle value. diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 0a4ce1e95c..307d8f703b 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -7,12 +7,15 @@ import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { createChatStore } from '../stores.ts' import type { ToolCallId, SelectionTarget } from './store.ts' import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts' +import type { TurnProcessSpec } from './turn-process.ts' +import type { TranscriptViewMode } from '../../chat-settings.ts' /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook @@ -65,6 +68,16 @@ export interface ChatNodeOwnerProps { forkAt: (seq: number) => void renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined + /** Turn-process state when this Node belongs to a projected Turn. */ + turnProcess?: TurnProcessOwnerProps | undefined +} + +/** Shared presentation state for one Turn-process answer generation. */ +export interface TurnProcessOwnerProps { + readonly spec: TurnProcessSpec + readonly foldable: boolean + readonly open: boolean + setOpen(open: boolean): void } /** Full props of one keyed Chat renderer. */ @@ -98,6 +111,10 @@ export interface ChatScrollPosition { /** Business callbacks injected into the Chat view. */ export interface ChatViewInjected { + hooks: { + /** Persisted completed-Turn transcript presentation. */ + transcriptView: SnapshotStore + } openDetails: (target: SelectionTarget) => void openFile: (path: string) => Promise loadOlder: () => void diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts index 94d2d914a7..f5f22a4f85 100644 --- a/packages/client/ui-chat/src/client/contract/store.ts +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -1,5 +1,7 @@ /** Chat-owned selection state shared by the transcript and details panel. */ +import type { TurnProcessGeneration } from './turn-process.ts' + /** Tool call identity as carried by Chat nodes. */ export type ToolCallId = string @@ -11,7 +13,14 @@ export interface SelectionTarget { toolName?: string } +/** One manually expanded Turn answer generation. */ +export interface TurnProcessViewEntry { + readonly turn: number + readonly generation: TurnProcessGeneration +} + /** Per-Session state shared only by the Chat view and details surface. */ export interface ChatStoreState { selection: SelectionTarget | null + turnProcesses: TurnProcessViewEntry[] } diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts new file mode 100644 index 0000000000..791830dead --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -0,0 +1,100 @@ +import type { ChatNode } from './chat-nodes.ts' + +/** Turn-local process window encoded as a reference-stable Location-data scalar. */ +export type TurnProcessSignature = string + +/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */ +export type TurnProcessGeneration = string + +/** Current process range and finalized answer boundary derived from one Turn. */ +export interface TurnProcessSpec { + readonly turn: number + /** Stable control-node anchor source, including currently ineligible evidence. */ + readonly controlAnchorSeq: number + readonly processStartSeq: number + readonly answerAnchorSeq: number | null + readonly answerStep: number | null + readonly inlineReasoning: boolean + /** Reply-bearing durable Assistant messages before the final answer. */ + readonly messageCount: number + /** Durable non-subagent Tool calls recorded by this Turn. */ + readonly toolCallCount: number + /** Tool calls whose configured name identifies a subagent delegation. */ + readonly subagentCount: number +} + +const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ + 'system-prompt', + 'user', + 'steering', + 'turn-process', + 'turn-error', + 'turn-max-tokens', + 'turn-tail', +] as const satisfies readonly ChatNode['kind'][] + +/** Chat Node kinds that remain independent of a Turn's process disclosure. */ +export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet = new Set( + TURN_PROCESS_INDEPENDENT_KIND_LIST, +) + +/** + * Identify one finalized answer generation without using its ordering anchor. + * @param spec - current Turn process specification. + * @returns stable identity until the finalized answer Step is withdrawn or replaced. + */ +export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration { + return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}` +} + +/** + * Encode one process specification as a primitive Location-data value. + * @param spec - current Turn process specification. + * @returns reference-stable scalar for equal specifications. + */ +export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature { + return [ + spec.turn, + spec.controlAnchorSeq, + spec.processStartSeq, + spec.answerAnchorSeq ?? '', + spec.answerStep ?? '', + spec.inlineReasoning ? 1 : 0, + spec.messageCount, + spec.toolCallCount, + spec.subagentCount, + ].join('|') +} + +/** + * Decode a same-process signature produced by {@link encodeTurnProcess}. + * @param signature - encoded Turn process value. + * @returns decoded process specification. + */ +export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec { + const [ + turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning, + messageCount, toolCallCount, subagentCount, + ] = signature.split('|') + return { + turn: Number(turn), + controlAnchorSeq: Number(controlAnchorSeq), + processStartSeq: Number(processStartSeq), + answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq), + answerStep: answerStep === '' ? null : Number(answerStep), + inlineReasoning: inlineReasoning === '1', + messageCount: Number(messageCount), + toolCallCount: Number(toolCallCount), + subagentCount: Number(subagentCount), + } +} + +/** + * Recognize the shipped subagent delegation name and its configured variants. + * Control tools use distinct names such as `send_message` and `list_agents`. + * @param name - durable Tool-call name. + * @returns whether the call creates or forks a subagent. + */ +export function isSubagentDelegationTool(name: string): boolean { + return name === 'subagent' || name.startsWith('subagent_') +} 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 ba25bf6464..74fa8c6ac8 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 @@ -9,6 +9,7 @@ import type { ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode, LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem, } from '../contract/snapshot.ts' +import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels } from './event-projection.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' @@ -192,10 +193,101 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s return {} } -function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] { - return nodes - .filter(node => node.visibility === 'visible') - .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) +interface TurnProcessPresentation { + readonly control?: ChatNode<'turn-process'> + readonly openingHumanAnchor?: number + readonly earliestProcessAnchor?: number +} + +function turnProcessPresentations( + nodes: readonly ChatConversationViewNode[], +): ReadonlyMap { + const presentations = new Map() + for (const raw of nodes) { + const node = raw as ChatNode + if (node.kind === 'turn-process') { + presentations.set(node.data.turn, { ...presentations.get(node.data.turn), control: node }) + } + } + for (const raw of nodes) { + const node = raw as ChatNode + const location = node.location + if (location.kind !== 'turn' && location.kind !== 'step') continue + const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} + if ((node.kind === 'user' || node.kind === 'steering') + && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) { + presentations.set(location.turn.turn, { + ...current, + openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq), + }) + continue + } + if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue + presentations.set(location.turn.turn, { + ...current, + earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq), + }) + } + return presentations +} + +interface PresentationPosition { + readonly anchor: number + readonly rank: number + readonly originalAnchor: number +} + +function presentationPosition( + raw: ChatConversationViewNode, + presentations: ReadonlyMap, +): PresentationPosition { + const node = raw as ChatNode + const location = node.location + if (location.kind !== 'turn' && location.kind !== 'step') { + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } + } + const presentation = presentations.get(location.turn.turn) + if (presentation === undefined) { + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } + } + const openingHumanAnchor = presentation.openingHumanAnchor + if (openingHumanAnchor !== undefined + && node.anchorSeq < openingHumanAnchor + && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) { + return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq } + } + if (presentation.control !== undefined && node.key === presentation.control.key) { + return openingHumanAnchor === undefined + ? { + anchor: presentation.earliestProcessAnchor ?? node.anchorSeq, + rank: -1, + originalAnchor: node.anchorSeq, + } + : { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq } + } + return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq } +} + +/** + * Order visible Chat Nodes without changing existing relative order as process + * eligibility changes. Opening human input precedes process candidates, while + * each synthetic process control sits between them. + * @param nodes - currently materialized Chat Nodes. + * @returns visible Nodes in presentation order. + */ +export function orderedVisibleChatNodes( + nodes: readonly ChatConversationViewNode[], +): ChatConversationViewNode[] { + const visible = nodes.filter(node => node.visibility === 'visible') + const presentations = turnProcessPresentations(visible) + return visible.sort((left, right) => { + const leftPosition = presentationPosition(left, presentations) + const rightPosition = presentationPosition(right, presentations) + return leftPosition.anchor - rightPosition.anchor + || leftPosition.rank - rightPosition.rank + || leftPosition.originalAnchor - rightPosition.originalAnchor + || left.key.localeCompare(right.key) + }) } function referenceMessageSeq(node: ChatConversationViewNode): number | undefined { @@ -556,7 +648,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + this.order = orderedVisibleChatNodes(nodes).map(node => node.key) this.locations.rebuild(this.order, this.store) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline @@ -573,6 +665,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key) this.order = sameReferences(this.order, next) ? this.order : next this.locations.rebuild(this.order, this.store) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts index fd63c47cdd..5d0b123163 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -14,6 +14,7 @@ import type { export const CHAT_SYNTHETIC_SEQ_OFFSETS = { interruptedAssistant: -0.9, interruptedFollowup: -0.8, + processControl: -0.1, maxTokensNotice: 0.05, finalizedFollowup: 0.1, } as const diff --git a/packages/client/ui-chat/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts index d40fb1b00a..0754aeb6a0 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/register.ts @@ -11,6 +11,7 @@ import { registerRetryConversationNode } from './retry.ts' import { registerToolConversationNode } from './tool.ts' import { registerTurnErrorConversationNode } from './turn-error.ts' import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts' +import { registerTurnProcess } from './turn-process.ts' import { registerTurnTailConversationNode } from './turn-tail.ts' /** @@ -22,6 +23,7 @@ export function registerConversationNodes(ctx: Context): void { registerMessageConversationNode(ctx) registerRequestPromptConversationNode(ctx) registerAssistantConversationNode(ctx) + registerTurnProcess(ctx) registerToolConversationNode(ctx) registerCommandConversationNode(ctx) registerCompactionConversationNode(ctx) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index c7f25cf6db..2cf518924b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeDefinition, RequestPromptInspector, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNode } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { @@ -33,6 +34,19 @@ function requestPromptAnchor( : match.location.step.start?.seq ?? match.event.seq } +/** Keep an already rendered prompt at its page-lifetime presentation anchor. */ +function stableRequestPromptAnchor( + context: ConversationNodeContext, + match: ConversationMatch, + previous: Readonly | undefined, + isInitial: boolean, +): number { + const current = context.current.get('chat') as ChatNode | null | undefined + return current?.kind === 'system-prompt' + ? current.anchorSeq + : requestPromptAnchor(match, previous, isInitial) +} + /** * Request-header prompt Definition for the Chat target. * @param inspect - the shared prompt interpretation, supplied by the @@ -46,7 +60,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver match: event => event.type === 'request/header' ? { id: String(event.seq), role: 'start' } : null, - start: (_context, match, reader) => { + start: (context, match, reader) => { if (match.event.type !== 'request/header') { throw new Error('request-prompt start requires request/header') } @@ -57,7 +71,12 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver const inspection = inspect(previous?.prompt, match.event) const change = inspection.change?.kind return { - anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'), + anchorSeq: stableRequestPromptAnchor( + context, + match, + previous, + match.event.data.reason === 'initial', + ), showsPrompt: previous === undefined || match.event.data.reason !== 'change' || match.event.data.startsSeries === true diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts new file mode 100644 index 0000000000..fd04874615 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -0,0 +1,283 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types' +import type { + ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type {} from '@deepseek-ai/dsh-tools/types' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' +import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts' +import { + decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool, + type TurnProcessSignature, type TurnProcessSpec, +} from '../contract/turn-process.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import { toAssistantBlocks } from './event-projection.ts' + +declare module '../contract/chat-nodes.ts' { + interface ChatNodeDataMap { + /** Turn-level disclosure controlling process rows before the finalized answer. */ + 'turn-process': import('../contract/chat-nodes.ts').TurnProcessChatData + } +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationTurnDataMap { + /** Encoded process range and finalized answer boundary for this Turn. */ + 'turn-process': TurnProcessSignature + } +} + +interface TurnProcessState { + readonly turn: number + readonly assistantStartByStep: ReadonlyMap + readonly messageCountByStep: ReadonlyMap + readonly otherStartSeq?: number + readonly toolCallCount: number + readonly subagentCount: number +} + +type ConversationEvent = Parameters[0] + +function isChunkRunEvent(event: ConversationEvent): event is ChunkRowEvent { + return event.type === 'chunkrow/text-chunks' + || event.type === 'chunkrow/reasoning-chunks' + || event.type === 'chunkrow/tool-call-chunks' +} + +function eventTurn(event: ConversationEvent): number | undefined { + const data = event.data as unknown as { turn?: unknown } + return typeof data.turn === 'number' ? data.turn : undefined +} + +function visibleAssistantEvent(event: ConversationEvent): boolean { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== '' + if (chunk.type === 'block-start') { + return chunk.blockType !== 'text' + && chunk.blockType !== 'reasoning' + && chunk.blockType !== 'tool-call' + } + if (chunk.type !== 'block-end') return false + const block = chunk.block + if (block.type === 'tool-call') return false + if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== '' + return true + } + return event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && toAssistantBlocks(event.data.message.content).some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +type ProcessEvidence = + | { readonly kind: 'assistant'; readonly seq: number; readonly step: number } + | { readonly kind: 'other'; readonly seq: number } + +function processEvidence(event: ConversationEvent): ProcessEvidence | undefined { + if (isChunkRunEvent(event)) { + if (event.type === 'chunkrow/tool-call-chunks') return undefined + const firstVisible = event.data.texts.findIndex(text => text.trim() !== '') + return firstVisible < 0 + ? undefined + : { kind: 'assistant', seq: event.seq + firstVisible, step: event.data.step } + } + if (visibleAssistantEvent(event)) { + if (event.type !== 'assistant/chunk' && event.type !== 'assistant/message') return undefined + return { kind: 'assistant', seq: event.seq, step: event.data.step } + } + if (event.type === 'tool/call' + || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) + || event.type === 'llm/retry') return { kind: 'other', seq: event.seq } + return undefined +} + +function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { + const location: ConversationLocation | undefined = context.start?.location ?? context.matches.at(-1)?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined +} + +function fallbackState(context: ConversationNodeContext): TurnProcessState | undefined { + const turn = context.matches.map(match => eventTurn(match.event)).find(candidate => candidate !== undefined) + if (turn === undefined) return undefined + let state: TurnProcessState = { + turn, + assistantStartByStep: new Map(), + messageCountByStep: new Map(), + toolCallCount: 0, + subagentCount: 0, + } + for (const match of context.matches) state = updateProcessState(state, match.event) + return state +} + +function isFinalAssistant( + data: Readonly | undefined, +): data is Readonly { + return data?.finalNode !== undefined +} + +function latestAnswer(turn: TurnLocation): Readonly | null { + const latestStep = turn.steps.at(-1) + const data: Readonly | undefined = latestStep?.data.get('assistant-step') + if (!isFinalAssistant(data) || !hasAssistantReplyContent(data.blocks)) return null + return data.blocks.some(block => block.kind === 'tool-call') ? null : data +} + +function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null { + const controlAnchorSeq = Math.min( + state.otherStartSeq ?? Number.POSITIVE_INFINITY, + ...state.assistantStartByStep.values(), + ) + if (!Number.isFinite(controlAnchorSeq)) return null + const answer = latestAnswer(turn) + const counts = { + messageCount: answer === null + ? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0) + : [...state.messageCountByStep] + .filter(([step]) => step < answer.step) + .reduce((total, [, count]) => total + count, 0), + toolCallCount: state.toolCallCount, + subagentCount: state.subagentCount, + } + if (answer === null) { + return { + turn: turn.turn, + controlAnchorSeq, + processStartSeq: controlAnchorSeq, + answerAnchorSeq: null, + answerStep: null, + inlineReasoning: false, + ...counts, + } + } + const inlineReasoning = answer.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') + const earlierAssistantSeq = Math.min( + ...[...state.assistantStartByStep] + .filter(([step]) => step < answer.step) + .map(([, seq]) => seq), + ) + const externalProcessSeq = Math.min( + state.otherStartSeq ?? Number.POSITIVE_INFINITY, + earlierAssistantSeq, + ) + return { + turn: turn.turn, + controlAnchorSeq, + processStartSeq: turn.start?.seq + ?? (Number.isFinite(externalProcessSeq) ? externalProcessSeq : answer.finalNode.seq), + answerAnchorSeq: answer.finalNode.seq, + answerStep: answer.step, + inlineReasoning, + ...counts, + } +} + +function updateProcessState(state: TurnProcessState, event: ConversationEvent): TurnProcessState { + let current = state + if (event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) { + const messageCountByStep = new Map(current.messageCountByStep) + messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1) + current = { ...current, messageCountByStep } + } + if (event.type === 'tool/call') { + const subagent = isSubagentDelegationTool(event.data.name) + current = { + ...current, + toolCallCount: current.toolCallCount + (subagent ? 0 : 1), + subagentCount: current.subagentCount + (subagent ? 1 : 0), + } + } + const evidence = processEvidence(event) + if (evidence === undefined) return current + if (evidence.kind === 'other') { + return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current + } + if (current.assistantStartByStep.has(evidence.step)) return current + const assistantStartByStep = new Map(current.assistantStartByStep) + assistantStartByStep.set(evidence.step, evidence.seq) + return { ...current, assistantStartByStep } +} + +/** Turn-scoped process range and answer-boundary Definition. */ +export const turnProcessDefinition: ConversationNodeDefinition = { + kind: 'turn-process', + target: 'chat', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + const turn = eventTurn(event) + if (turn === undefined) return null + if (event.type === 'assistant/chunk' + || event.type === 'assistant/message' + || isChunkRunEvent(event) + || event.type === 'tool/call' + || event.type === 'tool/result' + || event.type === 'llm/retry' + || event.type === 'step/start' + || event.type === 'step/end' + || event.type === 'turn/end') { + return { id: String(turn), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-process start requires turn/start') + return { + turn: match.event.data.turn, + assistantStartByStep: new Map(), + messageCountByStep: new Map(), + toolCallCount: 0, + subagentCount: 0, + } + }, + update: (context, match) => updateProcessState(context.state, match.event), + publication: (match) => { + if (isChunkRunEvent(match.event)) return 'animation-frame' + if (match.event.type === 'assistant/chunk') { + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + } + return 'immediate' + }, + buildLocationData: (context, scope) => { + if (scope !== 'turn') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const turn = turnLocation(context) + if (turn === undefined) return null + const spec = processSpec(state, turn) + return spec === null ? null : { + kind: 'turn', + turn: turn.turn, + key: 'turn-process', + value: encodeTurnProcess(spec), + } + }, + buildViewNode: (context) => { + const turn = turnLocation(context) + const signature = turn?.data.get('turn-process') + if (turn === undefined || signature === undefined) return null + const data = decodeTurnProcess(signature) + return chatNode( + context, + 'turn-process', + data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl, + data, + ) + }, +} + +/** + * Register the Turn-scoped process disclosure projection. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnProcess(ctx: Context): void { + ctx.uiConversation.events.register(turnProcessDefinition) +} diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 62893382b6..f45b686af5 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -10,6 +10,7 @@ export type {} from './conversation-nodes/retry.ts' export type {} from './conversation-nodes/tool.ts' export type {} from './conversation-nodes/turn-error.ts' export type {} from './conversation-nodes/turn-max-tokens.ts' +export type {} from './conversation-nodes/turn-process.ts' export type {} from './conversation-nodes/turn-tail.ts' export type { @@ -23,15 +24,21 @@ export type { export type { AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind, FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, - TurnTailChatData, + TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' -export type { ToolCallId, ChatStoreState, SelectionTarget } from './contract/store.ts' +export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' +export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' +export type { TranscriptViewMode } from '../chat-settings.ts' export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + DetailsToolOwnerProps, MessageImagesProps, + TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' +export type { + TurnProcessGeneration, TurnProcessSignature, TurnProcessSpec, +} from './contract/turn-process.ts' export type { ChatKey } from './locale.ts' export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' export type { diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 78321ef9f4..3c211c9d05 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -32,6 +32,10 @@ export const zh = { 'chat.turnNavigation.label': '轮次导航', 'chat.turnNavigation.jump': '跳转到第 {turn} 轮', 'chat.turnNavigation.turn': '第 {turn} 轮', + 'settings.transcript.title': '对话显示', + 'settings.transcript.description': '控制已完成轮次的过程内容', + 'settings.transcript.normal': 'Normal', + 'settings.transcript.compact': 'Compact', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'fileOpen.folderTitle': '无法打开文件夹', @@ -61,6 +65,14 @@ export const zh = { 'message.think': '思考', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', + 'message.turnProcess.toolCalls.one': '{count} 次工具调用', + 'message.turnProcess.toolCalls.other': '{count} 次工具调用', + 'message.turnProcess.messages.one': '{count} 条消息', + 'message.turnProcess.messages.other': '{count} 条消息', + 'message.turnProcess.subagents.one': '{count} 个 subagent', + 'message.turnProcess.subagents.other': '{count} 个 subagent', + 'message.turnProcess.thoughtForAWhile': '已思考', + 'message.turnProcess.separator': ' · ', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', @@ -133,6 +145,10 @@ export const en = { 'chat.turnNavigation.label': 'Turn navigation', 'chat.turnNavigation.jump': 'Jump to turn {turn}', 'chat.turnNavigation.turn': 'Turn {turn}', + 'settings.transcript.title': 'Conversation display', + 'settings.transcript.description': 'Controls process content in completed turns', + 'settings.transcript.normal': 'Normal', + 'settings.transcript.compact': 'Compact', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'fileOpen.folderTitle': 'Couldn’t open folder', @@ -162,6 +178,14 @@ export const en = { 'message.think': 'Think', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', + 'message.turnProcess.toolCalls.one': '{count} tool call', + 'message.turnProcess.toolCalls.other': '{count} tool calls', + 'message.turnProcess.messages.one': '{count} message', + 'message.turnProcess.messages.other': '{count} messages', + 'message.turnProcess.subagents.one': '{count} subagent', + 'message.turnProcess.subagents.other': '{count} subagents', + 'message.turnProcess.thoughtForAWhile': 'Thought for a while', + 'message.turnProcess.separator': ' · ', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css new file mode 100644 index 0000000000..496e01add3 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css @@ -0,0 +1,56 @@ +/* Completed-Turn transcript preference row: label plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx new file mode 100644 index 0000000000..f2ab902852 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -0,0 +1,79 @@ +/** General Settings row for completed-Turn transcript presentation. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TranscriptViewMode } from '../../chat-settings.ts' +import type { ChatKey } from '../locale.ts' +import css from './TranscriptViewRow.module.css' + +/** Registration-side transcript preference face. */ +export interface TranscriptViewRowInjected { + hooks: { + /** Persisted transcript preference bound as useTranscriptView. */ + transcriptView: SnapshotStore + } + /** Change the completed-Turn transcript presentation. */ + setTranscriptView: (mode: TranscriptViewMode) => void +} + +/** Full Settings-row props. */ +export type TranscriptViewRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'chat'> + & InjectFace + +const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ + { id: 'normal', label: 'settings.transcript.normal' }, + { id: 'compact', label: 'settings.transcript.compact' }, +] + +/** + * Render the completed-Turn transcript 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 [open, setOpen] = useState(false) + const selectedLabel = mode === 'normal' + ? 'settings.transcript.normal' + : 'settings.transcript.compact' + const closeMenu = () => { setOpen(false) } + const selectMode = (id: string) => { + closeMenu() + setTranscriptView(id as TranscriptViewMode) + } + const selector = ( + + ) + + return ( +
+
+
{t('settings.transcript.title')}
+
{t('settings.transcript.description')}
+
+ ({ id: option.id, label: t(option.label) }))} + selectedId={mode} + onSelect={selectMode} + align="end" + portal + anchor={selector} + /> +
+ ) +} diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts index 7ff6b80ad7..be2c411f7b 100644 --- a/packages/client/ui-chat/src/client/stores.ts +++ b/packages/client/ui-chat/src/client/stores.ts @@ -1,9 +1,29 @@ /** Per-Session Chat selection store shared by the transcript and details panel. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' -import type { ChatStoreState, SelectionTarget } from './contract/store.ts' +import type { TurnProcessGeneration } from './contract/turn-process.ts' +import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts' type ChatActions = { select: (draft: ChatStoreState, target: SelectionTarget | null) => void + setTurnProcessOpen: ( + draft: ChatStoreState, + turn: number, + generation: TurnProcessGeneration, + open: boolean, + ) => void +} + +/** + * Resolve any stored generation for one Turn. + * @param state - Chat store snapshot. + * @param turn - owning Turn. + * @returns the Turn's stored entry, when present. + */ +export function storedTurnProcessEntry( + state: Readonly, + turn: number, +): Readonly | undefined { + return state.turnProcesses.find(entry => entry.turn === turn) } /** @@ -12,9 +32,19 @@ type ChatActions = { */ export function createChatStore(): EngineStoreHandle { return defineStore({ - init: (): ChatStoreState => ({ selection: null }), + init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }), actions: { select: (draft, target: SelectionTarget | null) => { draft.selection = target }, + setTurnProcessOpen: (draft, turn, generation, open) => { + const index = draft.turnProcesses.findIndex(entry => entry.turn === turn) + if (!open) { + if (index >= 0) draft.turnProcesses.splice(index, 1) + return + } + const next = { turn, generation } satisfies TurnProcessViewEntry + if (index < 0) draft.turnProcesses.push(next) + else draft.turnProcesses[index] = next + }, }, }) } diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts new file mode 100644 index 0000000000..5534a0139c --- /dev/null +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -0,0 +1,39 @@ +/** Host-backed completed-Turn transcript 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, + type ChatSettings, type TranscriptViewMode, +} from '../chat-settings.ts' + +/** Live transcript 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) + + /** + * @param host - durable Chat settings scope. + */ + constructor(private readonly host: SettingsScope) { + host.subscribe(() => { this.adopt() }) + this.adopt() + } + + /** + * Publish and persist one explicit user choice. + * @param mode - Normal or Compact transcript presentation. + */ + setMode(mode: TranscriptViewMode): void { + if (this.mode.getSnapshot() === mode) return + this.mode.set(mode) + void this.host.set(TRANSCRIPT_VIEW_FIELD, mode) + } + + /** 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) + } +} diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts index 4c47106067..0faa47d878 100644 --- a/packages/client/ui-chat/src/index.ts +++ b/packages/client/ui-chat/src/index.ts @@ -1,4 +1,20 @@ -/** Host loader entry for the browser-only Chat UI target. */ +/** Host registration for browser Chat preferences. */ -/** Provides no Host-side behavior. */ -export function apply(): void {} +import type { Context } from '@deepseek-ai/cordis' +import { settingsNamespace } 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, + TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode, +} from './chat-settings.ts' + +/** Register the durable Chat settings section when a provider exists. */ +export function apply(ctx: Context): void { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.register( + settingsNamespace(CHAT_SETTINGS_NAMESPACE), + ChatSettingsSchema, + ) + }) +} diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index 5287b68272..ebe9359b68 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -61,9 +61,9 @@ describe('ApprovalCommand', () => { }) describe('ui-chat package entries', () => { - it('keeps the Host half inert and registers the invariant companion', async () => { - expect(() => { nodeApply() }).not.toThrow() + it('keeps the Host half optional and registers the invariant companion', async () => { const ctx = new Context() + expect(() => { nodeApply(ctx) }).not.toThrow() await ctx.plugin(InvariantRegistry, { enabled: true }) await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined() diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index eaf64ab256..537bea3e56 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -15,8 +15,9 @@ import { apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { - ChatNodeTurnDataInjected, ChatSnapshot, UseChat, + ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChat, } from '@deepseek-ai/dsh-client-ui-chat/client' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ConversationTurnDataMap { @@ -30,7 +31,12 @@ const SID = 'session-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + const chatSettings = stubSettingsScope() + runtime.ctx.provide('settingsScope', { + bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE + ? chatSettings.scope + : stubSettingsScope().scope, + } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), @@ -53,7 +59,7 @@ async function bench() { const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) const sourceDescriptor = provide.mock.calls[0]?.[0] if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source') - return { runtime, conversation, chat, sourceDescriptor } + return { runtime, conversation, chat, chatSettings, sourceDescriptor } } function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') { @@ -70,10 +76,30 @@ describe('Chat apply wiring', () => { .toMatchObject({ kind: 'keyed', scope: 'session' }) expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) .toEqual(['stats']) + expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) + .toEqual(['transcript-view', 'composer-enter']) expect(b.runtime.slots.entries('details')).toHaveLength(1) await b.runtime.dispose() }) + it('mirrors the Host transcript preference into its Settings row', async () => { + const b = await bench() + const row = b.runtime.slots.entries('settings.general.item') + .find(entry => entry.options.id === 'transcript-view')! + 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') + + b.chatSettings.publish({ + status: 'ready', value: { transcriptView: 'compact' }, revision: 1, writable: true, + }) + expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') + await b.runtime.dispose() + }) + it('shares one Chat store while keeping it distinct from Conversation state', async () => { const b = await bench() const conversationStore = storeOf(b.runtime, 'conversation.session') diff --git a/packages/client/ui-chat/tests/chat-settings.client.spec.ts b/packages/client/ui-chat/tests/chat-settings.client.spec.ts new file mode 100644 index 0000000000..cd23c41763 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-settings.client.spec.ts @@ -0,0 +1,37 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { + CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, apply, +} from '../src/index.ts' + +class MemorySettings extends SettingsProvider { + readonly writable = true + protected load(): Promise> { return Promise.resolve({}) } + protected persist(_ns: SettingsNamespace, _section: Record): Promise { + return Promise.resolve() + } +} + +describe('ui-chat Host settings', () => { + it('registers, validates, and disposes the transcript-view namespace', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings).await() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const ns = settingsNamespace(CHAT_SETTINGS_NAMESPACE) + + expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE }) + await ctx.settings.update(ns, { transcriptView: 'normal' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal' }) + await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow() + + await fiber.dispose() + expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) + }) + + it('loads without a settings provider', async () => { + const ctx = new Context() + await expect(ctx.plugin({ apply }).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index dce67d27ec..39920f0201 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,7 +1,7 @@ import type { - AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, - ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice, - PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, + AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, + ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData, + LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, @@ -10,6 +10,12 @@ import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts' import { sameTurnNavigationItem, turnNavigationItem, } from '../src/client/conversation-nodes/turn-navigation.ts' +import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' +import { + encodeTurnProcess, isSubagentDelegationTool, TURN_PROCESS_INDEPENDENT_KINDS, + type TurnProcessSpec, +} from '../src/client/contract/turn-process.ts' const EMPTY: readonly never[] = [] @@ -17,17 +23,45 @@ function sameValues(left: readonly T[], right: readonly T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } +function sameFixtureLocation( + left: ChatConversationViewNode['location'], + right: ChatConversationViewNode['location'], +): boolean { + if (left.kind !== right.kind) return false + if (left.kind === 'session' || left.kind === 'unresolved') return true + if (right.kind === 'session' || right.kind === 'unresolved') return false + if (left.turn.turn !== right.turn.turn + || left.turn.status !== right.turn.status + || left.turn.start !== right.turn.start + || left.turn.end !== right.turn.end + || left.turn.data !== right.turn.data) return false + if (left.kind === 'turn' || right.kind === 'turn') return left.kind === right.kind + return left.step.step === right.step.step + && left.step.status === right.step.status + && left.step.start === right.step.start + && left.step.end === right.step.end + && left.step.data === right.step.data +} + function nodeSource(node: ChatConversationViewNode): unknown { if (node.kind === 'assistant-step') { - const data = node.data as ReturnType + const data = node.data as AssistantChatData return data.finalNode ?? data.blocks } if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq + if (node.kind === 'turn-process') { + const data = node.data as TurnProcessSpec + return encodeTurnProcess(data) + } return node.data } +function toolCallName(call: ToolCallBlock): string | null { + return 'name' in call ? call.name : call.call?.name ?? null +} + class FixtureNodeStore implements ChatNodeStore { private byKey = new Map() private list: readonly ChatConversationViewNode[] = EMPTY @@ -47,6 +81,7 @@ class FixtureNodeStore implements ChatNodeStore { const node = previous !== undefined && previous.kind === candidate.kind && previous.anchorSeq === candidate.anchorSeq + && sameFixtureLocation(previous.location, candidate.location) && previous.visibility === candidate.visibility && nodeSource(previous) === nodeSource(candidate) ? previous @@ -97,7 +132,7 @@ class FixtureTurnDataStore implements ConversationLocationDataStore, + inferredTurn?: number, ): ChatConversationViewNode { - const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined + const ownTurn = 'turn' in node && typeof node.turn === 'number' ? node.turn : inferredTurn + const turn = ownTurn === undefined ? undefined : turns.get(ownTurn) const base = { key: `fixture:${node.kind}:${node.seq}`, id: String(node.seq), @@ -161,7 +198,8 @@ export function chatSnapshotFixture(input: { for (const turn of [...turnNumbers].sort((left, right) => left - right)) { const timing = legacy.turnTimings.get(turn) const endSeq = legacy.turnEnds.get(turn) - const data = new FixtureTurnDataStore() + const previousData = previous?.timeline.turns.get(turn)?.data + const data = previousData instanceof FixtureTurnDataStore ? previousData : new FixtureTurnDataStore() turnData.set(turn, data) turns.set(turn, { turn, @@ -177,7 +215,7 @@ export function chatSnapshotFixture(input: { }) } const linkedCompactions = new Set() - const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => { + const nodes = legacy.nodes.flatMap((node, index): ChatConversationViewNode[] => { if (node.kind === 'command' && node.name === 'compact') { const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined const candidates = sourceSeq === undefined @@ -198,7 +236,12 @@ export function chatSnapshotFixture(input: { } } if (node.kind === 'compaction' && linkedCompactions.has(node)) return [] - return [settledNode(node, turns)] + const inferredTurn = node.kind === 'tool-result' + ? legacy.nodes.slice(0, index).findLast( + (candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant', + )?.turn + : undefined + return [settledNode(node, turns, inferredTurn)] }) if (legacy.partial !== null) { const turn = turns.get(legacy.partial.turn) @@ -232,6 +275,75 @@ export function chatSnapshotFixture(input: { data: { root: call }, }) } + for (const [turnNumber, dataStore] of turnData) { + const inTurn = nodes.filter((candidate) => { + const location = candidate.location + return (location.kind === 'turn' || location.kind === 'step') && location.turn.turn === turnNumber + }) + const assistants = inTurn + .filter(candidate => candidate.kind === 'assistant-step') + .map(candidate => candidate.data as AssistantChatData) + const toolCalls = inTurn + .filter(candidate => candidate.kind === 'tool-call') + .map(candidate => (candidate.data as { readonly root: ToolCallBlock }).root) + const latestStep = Math.max( + 0, + ...assistants.map(candidate => candidate.step), + ...inTurn.flatMap((candidate) => { + if (candidate.kind !== 'tool-call') return [] + const root = (candidate.data as { root: ToolCallBlock }).root as ToolCallBlock & { step?: unknown } + const step: unknown = root.step + return typeof step === 'number' ? [step] : [] + }), + ) + const answer = assistants.findLast((candidate): candidate is FinalAssistantChatData => + candidate.step === latestStep + && candidate.finalNode !== undefined + && hasAssistantReplyContent(candidate.blocks) + && !candidate.blocks.some(block => block.kind === 'tool-call')) + const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step' + || candidate.kind === 'tool-call' + || candidate.kind === 'model-retry') + if (controlAnchor === undefined) continue + const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) + ?? controlAnchor + const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true + const spec: TurnProcessSpec = { + turn: turnNumber, + controlAnchorSeq: controlAnchor.anchorSeq, + processStartSeq: processStart.anchorSeq, + answerAnchorSeq: answer?.finalNode.seq ?? null, + answerStep: answer?.step ?? null, + inlineReasoning: answer !== undefined && inlineReasoning, + messageCount: answer === undefined + ? assistants.filter(candidate => hasAssistantReplyContent(candidate.blocks)).length + : assistants.filter(candidate => candidate.step < answer.step + && hasAssistantReplyContent(candidate.blocks)).length, + toolCallCount: toolCalls.filter((call) => { + const name = toolCallName(call) + return name === null || !isSubagentDelegationTool(name) + }).length, + subagentCount: toolCalls.filter((call) => { + const name = toolCallName(call) + return name !== null && isSubagentDelegationTool(name) + }).length, + } + dataStore.set('turn-process', encodeTurnProcess(spec)) + const turn = turns.get(turnNumber) + if (turn !== undefined) { + nodes.push({ + key: `fixture:turn-process:${String(turnNumber)}`, + id: String(turnNumber), + target: 'chat', + kind: 'turn-process', + anchorSeq: spec.controlAnchorSeq - 0.1, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: spec, + }) + } + } + nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) for (const [turnNumber, endSeq] of legacy.turnEnds) { const turn = turns.get(turnNumber) const dataStore = turnData.get(turnNumber) @@ -271,10 +383,12 @@ export function chatSnapshotFixture(input: { data: tailData, }) } + nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + const ordered = orderedVisibleChatNodes(nodes) const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore() - store.replace(nodes) + store.replace(ordered) const byKey = new Map(store.values().map(node => [node.key, node])) - const nextOrder = nodes.map(node => node.key) + const nextOrder = ordered.map(node => node.key) const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder const byTurn = new Map() for (const turn of turns.keys()) { diff --git a/packages/client/ui-chat/tests/chat-store.client.spec.ts b/packages/client/ui-chat/tests/chat-store.client.spec.ts index efdda72dae..e0f09bfda1 100644 --- a/packages/client/ui-chat/tests/chat-store.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-store.client.spec.ts @@ -4,7 +4,7 @@ import { createChatStore } from '../src/client/stores.ts' describe('createChatStore', () => { it('starts without a selected Chat target', () => { const store = createChatStore().create() - expect(store.store.getSnapshot()).toEqual({ selection: null }) + expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) }) it('selects and clears one Chat details target', () => { @@ -23,4 +23,27 @@ describe('createChatStore', () => { first.actions.select({ turnSeq: 1 }) expect(second.store.getSnapshot().selection).toBeNull() }) + + it('stores only manually expanded Turn-process generations', () => { + const store = createChatStore().create() + store.actions.setTurnProcessOpen(2, '2|3', true) + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|3' }]) + + store.actions.setTurnProcessOpen(2, '2|4', true) + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|4' }]) + + store.actions.setTurnProcessOpen(2, '2|4', false) + expect(store.store.getSnapshot().turnProcesses).toEqual([]) + }) + + it('closes only the requested Turn-process entry', () => { + const store = createChatStore().create() + store.actions.setTurnProcessOpen(2, '2|3', true) + store.actions.setTurnProcessOpen(3, '3|4', true) + + store.actions.setTurnProcessOpen(2, '2|3', false) + store.actions.setTurnProcessOpen(9, '9|10', false) + + expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, generation: '3|4' }]) + }) }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 58b31b4d8e..85a5d1f0c9 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -5,10 +5,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { useEffect } from 'react' import type { AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot, - ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, - ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, - UseChatNodeTurnData, UserMessageNode, + ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, + LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode, + ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData, + TranscriptViewMode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState, SessionSnapshot, @@ -23,6 +23,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' +import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx' import { zh } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' @@ -31,7 +32,11 @@ import { TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' +import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx' +import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { encodeTurnProcess } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' afterEach(() => { @@ -88,6 +93,7 @@ function makeSessionSource(init: Partial = {}) { } type ChatSlice = Partial +type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot } /** Scripted Chat target source, independent from Session lifecycle state. */ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { @@ -98,6 +104,10 @@ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) { snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap) for (const fn of [...subs]) fn() }, + replace: (next: ChatSnapshot) => { + snap = next + for (const fn of [...subs]) fn() + }, source: { getSnapshot: () => snap, subscribe: (fn: () => void) => { @@ -121,8 +131,20 @@ const userInTurn = (seq: number, text: string, turn: number): ConversationNode = // accepts the extra coordinate so component tests can build the same view. turn, } as unknown as ConversationNode) -const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }], +}) +const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }], +}) +const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({ + kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null, + provenance: { role: 'inject', label: null }, form: null, + ...(turn === undefined ? {} : { turn }), +}) +const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({ + kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'], + seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null, }) const retry = (seq: number): ModelRetryNode => ({ kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'], @@ -178,12 +200,23 @@ function emptyWorkspaces() { } function makeHarness( - chatSlice: ChatSlice = {}, - sessionInit: Partial = {}, + init: HarnessUpdate = {}, + sessionOverrides: Partial = {}, chatSnapshot?: ChatSnapshot, ) { - const session = makeSessionSource(sessionInit) - const chatSource = makeChatSource(chatSlice, chatSnapshot) + const { + chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, + ...sessionInit + } = init + const chatSlice: ChatSlice = { + ...(nodes === undefined ? {} : { nodes }), + ...(partial === undefined ? {} : { partial }), + ...(runningCalls === undefined ? {} : { runningCalls }), + ...(turnTimings === undefined ? {} : { turnTimings }), + ...(turnEnds === undefined ? {} : { turnEnds }), + } + const session = makeSessionSource({ ...sessionInit, ...sessionOverrides }) + const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot) const openDetails = vi.fn<(t: SelectionTarget) => void>() const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() @@ -197,6 +230,7 @@ function makeHarness( const forkAt = vi.fn() // Rows and the harness must observe the same chat-store instance. const chat = createChatStore().create() + const transcriptView = createSnapshotStore('compact') const t = makeTranslate(zh, commonZh) const toolOwners: Array<{ callId: string @@ -212,10 +246,12 @@ function makeHarness( React.ComponentProps['renderSlotChain'] const renderTurnTailSlot = (() => null) as unknown as React.ComponentProps['renderSlot'] - const renderSlot = ((key: string, owner: object, opts?: { + let nodeSlotOverride: React.ComponentProps['renderSlot'] | undefined + const renderNodeSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode hookContext?: unknown }) => { + if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never) if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined @@ -254,6 +290,10 @@ function makeHarness( return ()} /> case 'turn-max-tokens': return ()} /> + case 'turn-process': + return ()} /> + case 'system-prompt': + return ()} /> case 'turn-tail': return ( ['renderSlot'] + const renderSlot = renderNodeSlot // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} @@ -316,6 +357,7 @@ function makeHarness( }, useStore: bindSnapshotSelector(chat), actions: chat.actions, + useTranscriptView: bindSnapshotSelector(transcriptView), renderSlot, SessionProvider: SessionProviderStub, viewRequest: null, @@ -331,11 +373,33 @@ function makeHarness( fileMentions: () => undefined, t, } + const set = (next: HarnessUpdate): void => { + const { + chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds, + ...sessionUpdate + } = next + if (explicitChat !== undefined) chatSource.replace(explicitChat) + else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined + || turnTimings !== undefined || turnEnds !== undefined) { + chatSource.set({ + ...(nodes === undefined ? {} : { nodes }), + ...(partial === undefined ? {} : { partial }), + ...(runningCalls === undefined ? {} : { runningCalls }), + ...(turnTimings === undefined ? {} : { turnTimings }), + ...(turnEnds === undefined ? {} : { turnEnds }), + }) + } + session.set(sessionUpdate) + } const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) } return { - setSession: session.set, setChat: chatSource.set, ChatView, props, + set, setSession: session.set, setChat: chatSource.set, ChatView, props, openDetails, openFile, loadOlder, openView, chatScroll, forkAt, setSelection, toolOwners, + setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, + setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => { + nodeSlotOverride = renderer + }, } } @@ -346,6 +410,34 @@ function readerScroll(element: HTMLElement, top: number): void { fireEvent.scroll(element) } +function turnProcessControl(container: HTMLElement): HTMLButtonElement | null { + return container.querySelector('[data-turn-process]') +} + +function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot { + const turn = snapshot.timeline.turns.get(1) + if (turn === undefined) throw new Error('fixture lacks Turn 1') + const prompt: ChatNode<'system-prompt'> = { + key: 'fixture:system-prompt:1', + id: '1', + target: 'chat', + kind: 'system-prompt', + anchorSeq: 1, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: { text }, + } + return new ChatSnapshotBuilder().replace({ + nodes: [prompt, ...snapshot.nodes.values()], + timeline: snapshot.timeline, + }) +} + +function renderedFlowKinds(container: HTMLElement): Array { + return [...container.querySelectorAll('[data-chat-flow-kind]')] + .map(row => row.dataset.chatFlowKind) +} + function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) { let scrollHeight = initialHeight let scrollTop = 0 @@ -650,6 +742,7 @@ describe('ChatView', () => { kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ { key: 'fixture:user:1', kind: 'user' }, + { key: 'fixture:turn-process:1', kind: 'turn-process' }, { key: 'fixture:assistant:2', kind: 'assistant-step' }, { key: 'fixture:tool:a', kind: 'tool-call' }, { key: 'fixture:tool:b', kind: 'tool-call' }, @@ -658,7 +751,7 @@ describe('ChatView', () => { .toEqual(['a', 'b']) expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) .toEqual([ - 'fixture:user:1', 'fixture:assistant:2', + 'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2', 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', ]) }) @@ -927,9 +1020,9 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), - assistant(2, 'mid-turn text'), + assistant(2, 'mid-turn text', 1, 1), toolResult(3, 'a'), - assistant(4, 'final answer'), + assistant(4, 'final answer', 1, 2), user(5, 'next'), assistant(6, 'second turn', 2), ], @@ -943,6 +1036,464 @@ describe('ChatView', () => { expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null]) }) + it('folds Think and Tool rows before the final answer without unmounting them', () => { + const first = { + ...assistant(2, 'earlier reply', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: 'inspect the repository' }, + { kind: 'text' as const, text: 'earlier reply' }, + ], + } + const second = assistant(5, 'final answer', 1, 2) + const h = makeHarness({ + nodes: [ + user(1, 'question'), + first, + toolResult(3, 'a'), + toolResult(4, 'b', 'subagent'), + second, + ], + turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]), + turnEnds: new Map([[1, 6]]), + }) + const view = render() + const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1') + expect(toggle.getAttribute('data-turn-process-messages')).toBe('1') + expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1') + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + expect(members).toHaveLength(3) + expect(members.map(member => member.getAttribute('hidden'))) + .toEqual(['until-found', 'until-found', 'until-found']) + expect(members[0]?.textContent).toContain('inspect the repository') + expect(members[1]?.textContent).toContain('bash:a') + expect(members[2]?.textContent).toContain('subagent:b') + expect(view.getByText('final answer')).toBeTruthy() + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + + fireEvent.click(toggle) + expect(members.map(member => member.getAttribute('hidden'))) + .toEqual(['until-found', 'until-found', 'until-found']) + fireEvent(members[1]!, new Event('beforematch')) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + + act(() => { h.set({ nodes: [user(1, 'question'), first] }) }) + expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false') + expect(members[0]?.getAttribute('hidden')).toBeNull() + act(() => { h.set({ + nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second], + }) }) + const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + expect(renewedToggle.getAttribute('aria-expanded')).toBe('true') + expect(members[0]?.getAttribute('hidden')).toBeNull() + }) + + it('folds injected Context in place with the rest of the Turn process', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy changed', 1), + reasoningAssistant(3, 'inspect the repository', 1, 1), + toolResult(4, 'a'), + assistant(5, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 6]]), + }) + const view = render() + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + const members = [...view.container.querySelectorAll('[data-turn-process-member]')] + + expect(members).toHaveLength(3) + expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call']) + expect(contextRow).not.toBeNull() + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + fireEvent(contextRow!, new Event('beforematch')) + 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', () => { + const initial = withSystemPrompt(chatSnapshotFixture({ + nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)], + })) + const running = withSystemPrompt(chatSnapshotFixture({ + nodes: [ + userInTurn(2, 'question', 1), + context(3, 'runtime policy', 1), + reasoningAssistant(4, 'inspect', 1, 1), + ], + })) + const completed = withSystemPrompt(chatSnapshotFixture({ + nodes: [ + userInTurn(2, 'question', 1), + context(3, 'runtime policy', 1), + reasoningAssistant(4, 'inspect', 1, 1), + assistant(6, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 7]]), + })) + 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) + + act(() => { h.set({ chat: running, running: true }) }) + expect(renderedFlowKinds(view.container)).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', + ]) + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow) + expect(promptRow.getAttribute('hidden')).toBeNull() + + act(() => { h.set({ chat: completed, running: false }) }) + 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', + ]) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(promptRow.getAttribute('hidden')).toBeNull() + expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) + 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', + ]) + expect(promptRow.getAttribute('hidden')).toBeNull() + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) + }) + + it('folds Context under the fallback title when every summary count is zero', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)], + turnEnds: new Map([[1, 4]]), + }) + const view = render() + const toggle = view.getByRole('button', { name: '已思考' }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0') + expect(toggle.getAttribute('data-turn-process-messages')).toBe('0') + expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + fireEvent.click(toggle) + expect(contextRow?.getAttribute('hidden')).toBeNull() + }) + + it('keeps ordinary spacing when steering separates the process control from its answer', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + reasoningAssistant(2, 'inspect', 1, 1), + steering(3, 'also mention safety', 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') + + expect(view.getByText('also mention safety')).toBeTruthy() + expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) + }) + + it('keeps ordinary spacing when steering precedes the first process evidence', () => { + const h = makeHarness({ + nodes: [ + steering(1, 'question', 1), + steering(2, 'also mention safety', 1), + reasoningAssistant(3, 'inspect', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + const answer = view.container.querySelector('[data-chat-flow-kind="assistant-step"]:not([hidden])') + + expect(view.getByText('also mention safety')).toBeTruthy() + expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) + }) + + it('keeps a live Turn expanded and folds it once at turn/end', () => { + const process = assistant(2, 'inspect', 1, 1) + const h = makeHarness({ + nodes: [user(1, 'question'), process], + partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] }, + running: true, + }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + + act(() => { + h.set({ + nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)], + partial: null, + running: false, + turnEnds: new Map([[1, 5]]), + }) + }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(processRow.getAttribute('hidden')).toBe('until-found') + }) + + it('switches completed Turns between the persisted Normal and Compact 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]]), + }) + const view = render() + const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + + 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') + }) + + it('folds final-step reasoning under the fallback title when every summary count is zero', () => { + const final = { + ...assistant(3, 'final answer', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: 'private analysis' }, + { kind: 'text' as const, text: 'final answer' }, + ], + } + const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) }) + const view = render() + const toggle = view.getByRole('button', { name: '已思考' }) + const reasoning = view.container.querySelector('[data-turn-process-inline]') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(reasoning?.getAttribute('hidden')).toBe('until-found') + expect(view.getByText('final answer')).toBeTruthy() + fireEvent.click(toggle) + expect(view.getByText('private analysis')).toBeTruthy() + }) + + 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 view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + readerScroll(scroller, 100) + + act(() => { h.set({ + nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(firstRow.getAttribute('hidden')).toBe('until-found') + expect(view.getByLabelText('回到底部')).toBeTruthy() + }) + + it('folds when the process controller first appears off-tail', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + running: true, + }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + readerScroll(scroller, 100) + + act(() => { h.set({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'final answer', 1, 1), + ], + running: false, + turnEnds: new Map([[1, 4]]), + }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + expect(view.getByLabelText('回到底部')).toBeTruthy() + }) + + it('keeps a focused process row visible when a live Turn completes', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + running: true, + }) + const view = render() + const contextToggle = view.getByRole('button', { name: '上下文注入' }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + contextToggle.focus() + expect(document.activeElement).toBe(contextToggle) + + act(() => { h.set({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'final answer', 1, 1), + ], + running: false, + turnEnds: new Map([[1, 4]]), + }) }) + const processToggle = turnProcessControl(view.container)! + expect(processToggle.getAttribute('aria-expanded')).toBe('true') + expect(contextRow?.getAttribute('hidden')).toBeNull() + expect(document.activeElement).toBe(contextToggle) + + fireEvent.click(processToggle) + expect(document.activeElement).toBe(processToggle) + expect(processToggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + }) + + it('keeps a foldable closed Turn fully visible while history is partial', () => { + const h = makeHarness({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'working', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + hasMore: true, + }) + const view = render() + const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + + expect(turnProcessControl(view.container)).toBeNull() + expect(contextRow?.getAttribute('hidden')).toBeNull() + expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false) + + act(() => { h.set({ hasMore: false }) }) + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(contextRow?.getAttribute('hidden')).toBe('until-found') + }) + + it('withholds process controls for partial history and folds final-page groups', () => { + const h = makeHarness({ + nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)], + hasMore: true, + }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + + act(() => { + h.set({ + nodes: [ + user(1, 'older question'), + assistant(2, 'older first answer', 1, 1), + assistant(4, 'older final answer', 1, 2), + user(9, 'visible question'), + assistant(10, 'visible answer', 2), + ], + turnEnds: new Map([[1, 5]]), + hasMore: false, + }) + }) + + const toggle = turnProcessControl(view.container)! + const member = view.container.querySelector('[data-turn-process-member]') + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(member?.getAttribute('hidden')).toBe('until-found') + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(member?.getAttribute('hidden')).toBeNull() + }) + + it('refreshes process layout without reordering when the final page only changes Turn data', () => { + const source = chatSnapshotFixture({ + nodes: [ + user(1, 'question'), + context(2, 'runtime policy', 1), + assistant(3, 'working', 1, 1), + assistant(4, 'final answer', 1, 2), + ], + turnEnds: new Map([[1, 5]]), + }) + const process = source.nodes.values() + .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process') + if (process === undefined + || (process.location.kind !== 'turn' && process.location.kind !== 'step') + || process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process') + const turnData = process.location.turn.data as typeof process.location.turn.data & { + set(key: 'turn-process', value: ReturnType): void + } + const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq } + turnData.set('turn-process', encodeTurnProcess(partialSpec)) + const partialProcess = { ...process, data: partialSpec } + const builder = new ChatSnapshotBuilder() + const partial = builder.replace({ + nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node), + timeline: source.timeline, + }) + const h = makeHarness({ chat: partial, hasMore: true }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + + const beforeKeys = partial.locations.getTurn(1) + const completeSpec = { ...partialSpec, processStartSeq: 2 } + turnData.set('turn-process', encodeTurnProcess(completeSpec)) + const complete = builder.apply({ + upserts: [{ ...partialProcess, data: completeSpec }], + timeline: source.timeline, + }) + expect(complete.order).toBe(partial.order) + expect(complete.nodes).toBe(partial.nodes) + expect(complete.locations.getTurn(1)).not.toBe(beforeKeys) + expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + + act(() => { h.set({ chat: complete, hasMore: false }) }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + }) + + it('keeps a manual expansion when the reader returns from another view', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const first = assistant(2, 'first answer', 1, 1) + const h = makeHarness({ + nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) + const view = render(, { container: host }) + fireEvent.click(turnProcessControl(view.container)!) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true') + + view.rerender(
) + view.rerender() + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true') + } finally { + host.remove() + } + }) + it('withholds assistant IconActions while the turn is still running', () => { const h = makeHarness({ runningCalls: [runningCall('a')], @@ -973,8 +1524,8 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [ user(1, 'hi'), // time 1_000 - assistant(2, 'mid-turn text'), - assistant(16, 'final answer'), + assistant(2, 'mid-turn text', 1, 1), + assistant(16, 'final answer', 1, 2), toolResult(18, 'trailing'), ], turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]), @@ -982,7 +1533,7 @@ describe('ChatView', () => { }) const view = render() // The exact turn/end includes trailing tool activity after the final text. - expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) + expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') }) it('the settled footer appends first-step ttft and turn decode throughput', () => { @@ -1003,7 +1554,7 @@ describe('ChatView', () => { }) const view = render() // First-step ttft (1.2s) plus 100 tokens over 5s of decode. - expect(view.getAllByText(/用时 19秒/)).toHaveLength(1) + expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒') expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1) expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1) }) @@ -1126,8 +1677,8 @@ describe('ChatView', () => { h.setChat({ nodes: [ user(1, markdown), - assistant(2, markdown), - { ...assistant(3, markdown), interrupted: true }, + assistant(2, markdown, 1, 1), + { ...assistant(3, markdown, 1, 2), interrupted: true }, ], }) }) @@ -1160,12 +1711,12 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((key: string, owner: object) => { + h.setNodeRenderer(((key: string, owner: object) => { if (key !== 'conversation.chat.node' || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null rowRenders += 1 return
- }) + }) as React.ComponentProps['renderSlot']) const view = render() expect(view.getByTestId('counting-row')).toBeTruthy() const afterMount = rowRenders @@ -1219,7 +1770,7 @@ describe('ChatView', () => { return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' ? : opts?.fallback ?? null - }) as ChatViewSlotProps['renderSlot'] + }) as React.ComponentProps['renderSlot'] const view = render() const tool = view.getByTestId('stateful-tool') const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]') @@ -1270,10 +1821,10 @@ describe('ChatView', () => { const block = toolResult(3, 'a') const h = makeHarness({ nodes: [block] }) const calls: { key: string; owner: object; entryKey?: string }[] = [] - h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { + h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => { calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) }) return opts?.fallback ?? null - }) + }) as React.ComponentProps['renderSlot']) render() expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ 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 740f473669..5315045237 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 @@ -15,6 +15,8 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' +import { decodeTurnProcess } from '../src/client/contract/turn-process.ts' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import { commandDefinition } from '../src/client/conversation-nodes/command.ts' @@ -29,6 +31,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' +import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts' import type { AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, } from '../src/client/contract/chat-nodes.ts' @@ -39,6 +42,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ messageDefinition, requestPromptDefinition(inspectRequestPrompt), assistantDefinition, + turnProcessDefinition, toolDefinition, commandDefinition, compactionDefinition, @@ -221,6 +225,350 @@ describe('built-in conversation node Definitions', () => { expect(items[0]?.prompt.length).toBe(160) }) + it('classifies reply content separately from reasoning and Tool protocol blocks', () => { + expect(hasAssistantReplyContent([{ kind: 'text', text: ' ' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false) + expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true) + expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true) + expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true) + }) + + it('projects one reversible process window before the finalized answer', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', { + ...textMessage('context-1', 'workspace context'), + turn: 1, + step: 1, + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(4, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(5, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' }, + }), + at(6, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' }, + }), + ]) + const process = () => { + const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process') + return signature === undefined ? undefined : decodeTurnProcess(signature) + } + expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null }) + expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null }) + + value.append(at(7, 'tool/call', { + turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}', + })) + value.append(at(8, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-1', 'done'), + }, { surfaceOp: 'append' })) + value.append(at(9, 'step/end', { turn: 1, step: 1 })) + value.append(at(10, 'step/start', { turn: 1, step: 2 })) + value.append(at(11, 'assistant/chunk', { + turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' }, + })) + value.append(at(12, 'assistant/chunk', { + turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' }, + })) + value.flush() + expect(process()).toMatchObject({ + processStartSeq: 4, + answerAnchorSeq: null, + answerStep: null, + inlineReasoning: false, + }) + + value.append(at(13, 'llm/retry', { + retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + + value.append(at(14, 'assistant/chunk', { + turn: 1, + step: 2, + chunk: { type: 'text-delta', index: 0, text: 'replacement reply' }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + + value.append(at(15, 'step/end', { turn: 1, step: 2 })) + value.append(at(16, 'turn/end', { + turn: 1, + reason: { kind: 'aborted', reason: { kind: 'user' } }, + })) + value.flush() + expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 }) + + const recovered = assembler([ + at(20, 'turn/start', { turn: 2 }), + at(21, 'step/start', { turn: 2, step: 1 }), + at(22, 'assistant/message', { + turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'), + }, { surfaceOp: 'append' }), + at(23, 'step/end', { turn: 2, step: 1 }), + at(24, 'step/start', { turn: 2, step: 2 }), + at(25, 'assistant/chunk', { + turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' }, + }), + at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }), + ]) + const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process') + expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature)) + .toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 }) + + const partialWindow = assembler([ + at(30, 'assistant/chunk', { + turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' }, + }), + at(31, 'step/end', { turn: 3, step: 4 }), + ], true) + const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process') + expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature)) + .toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 }) + }) + + it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, step: 1, message: assistantMessage('message-1', 'checking'), + }, { surfaceOp: 'append' }), + at(4, 'tool/call', { + turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}', + }), + at(5, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-read', 'read done'), + }, { surfaceOp: 'append' }), + at(6, 'tool/call', { + turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}', + }), + at(7, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'), + }, { surfaceOp: 'append' }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'), + }, { surfaceOp: 'append' }), + at(11, 'step/end', { turn: 1, step: 2 }), + at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process') + expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({ + messageCount: 1, + toolCallCount: 1, + subagentCount: 1, + }) + }) + + it('orders the opening User before its process control and later steering', () => { + const steering = textMessage('steer-1', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', { + ...textMessage('context-1', 'runtime context'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }), + at(4, 'step/start', { turn: 1, step: 1 }), + ]) + const opening = snapshot(value) + expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([ + 'user', 'context', + ]) + + value.append(at(5, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + })) + value.flush() + const running = snapshot(value) + expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'assistant-step', + ]) + + value.append(at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + })) + value.append(at(7, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + })) + value.append(at(8, 'user/message', steering, { surfaceOp: 'append' })) + value.append(at(9, 'step/end', { turn: 1, step: 1 })) + value.append(at(10, 'step/start', { turn: 1, step: 2 })) + value.append(at(11, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.append(at(12, 'step/end', { turn: 1, step: 2 })) + value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) + value.flush() + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + }) + + it('orders a command-started Turn first steering before its process control', () => { + const steering = textMessage('command-task', 'plan this change') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + }), + at(3, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(4, 'user/message', steering, { surfaceOp: 'append' }), + at(5, 'step/start', { turn: 1, step: 1 }), + at(6, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'step/start', { turn: 1, step: 2 }), + at(9, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' }), + at(10, 'step/end', { turn: 1, step: 2 }), + at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + }) + + it('keeps a first human message after process evidence at its event position', () => { + const steering = textMessage('late-steering', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { + turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}', + }), + at(4, 'tool/result', { + turn: 1, step: 1, message: toolResult('call-1', 'done'), + }, { surfaceOp: 'append' }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [steering], + }), + at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(7, 'user/message', steering, { surfaceOp: 'append' }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' }), + at(11, 'step/end', { turn: 1, step: 2 }), + at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const current = snapshot(value) + + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail', + ]) + }) + + it('keeps Process before pre-User Context as answer eligibility changes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', { + ...textMessage('context-1', 'runtime context'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/chunk', { + turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + ]) + const running = snapshot(value) + expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', + ]) + + value.append(at(5, 'step/end', { turn: 1, step: 1 })) + value.append(at(6, 'step/start', { turn: 1, step: 2 })) + value.append(at(7, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.flush() + const answered = snapshot(value) + expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', 'assistant-step', + ]) + + value.append(at(8, 'llm/retry', { + retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + value.flush() + const retried = snapshot(value) + expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([ + 'turn-process', 'context', 'assistant-step', 'model-retry', + ]) + }) + + it('establishes the answer boundary only when a streamed answer finalizes', () => { + const value = assembler([ + at(40, 'turn/start', { turn: 4 }), + at(41, 'step/start', { turn: 4, step: 1 }), + at(42, 'assistant/chunk', { + turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }), + at(43, 'assistant/chunk', { + turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' }, + }), + ]) + const read = () => { + const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process') + if (signature === undefined) throw new Error('turn-process signature is unavailable') + return decodeTurnProcess(signature) + } + const streaming = read() + value.append(at(44, 'assistant/message', { + turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'), + }, { surfaceOp: 'append' })) + value.flush() + const settled = read() + + expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null }) + expect(settled.answerAnchorSeq).toBe(44) + expect(settled.answerStep).toBe(1) + }) + + it('anchors a streamed non-text answer from its block start', () => { + const value = assembler([ + at(50, 'turn/start', { turn: 5 }), + at(51, 'step/start', { turn: 5, step: 1 }), + at(52, 'assistant/chunk', { + turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' }, + }), + ]) + const current = snapshot(value) + const process = node(current, 'turn-process') + const answer = node(current, 'assistant-step') + const signature = current.timeline.turns.get(5)?.data.get('turn-process') + + expect(process?.anchorSeq).toBe(51.9) + expect(answer?.anchorSeq).toBe(52) + expect(signature === undefined ? undefined : decodeTurnProcess(signature)) + .toMatchObject({ answerAnchorSeq: null, answerStep: null }) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -638,10 +986,10 @@ describe('built-in conversation node Definitions', () => { const after = snapshot(value) expect(after.nodes).toBe(store) expect(after.nodes.get(existing?.key ?? '')).toBe(existing) - expect(after.order).toHaveLength(before.order.length + 3) + expect(after.order).toHaveLength(before.order.length + 4) expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ - 'user', 'assistant-step', 'turn-tail', - 'user', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'assistant-step', 'turn-tail', + 'user', 'turn-process', 'assistant-step', 'turn-tail', ]) }) @@ -671,7 +1019,7 @@ describe('built-in conversation node Definitions', () => { expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ - 'user', 'assistant-step', 'turn-tail', 'user', + 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user', ]) }) @@ -929,6 +1277,51 @@ describe('built-in conversation node Definitions', () => { expect(node(current, 'system-prompt')?.anchorSeq).toBe(1) }) + it('keeps the initial system prompt before the opening User as Turn process state changes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }), + at(4, 'user/message', { + ...textMessage('runtime-context', 'runtime facts'), + source: { kind: 'plugin', plugin: 'context' }, + }, { surfaceOp: 'append' }), + at(5, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'fake' }, system: '# System' }, + }), + ]) + const kinds = () => { + const current = snapshot(value) + return current.order.map(key => current.nodes.get(key)?.kind) + } + const promptKey = node(snapshot(value), 'system-prompt')?.key + + expect(kinds()).toEqual(['system-prompt', 'user', 'context']) + + value.append(at(6, 'assistant/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', + ]) + + value.append(at(7, 'step/end', { turn: 1, step: 1 })) + value.append(at(8, 'step/start', { turn: 1, step: 2 })) + value.append(at(9, 'assistant/message', { + turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'), + }, { surfaceOp: 'append' })) + value.append(at(10, 'step/end', { turn: 1, step: 2 })) + value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } })) + value.flush() + + expect(kinds()).toEqual([ + 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', + ]) + expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey) + }) + it('keeps an append-only later user turn in the existing system-prompt series', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -953,7 +1346,7 @@ describe('built-in conversation node Definitions', () => { expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user']) }) - it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => { + it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => { const reasons = ['change', 'resume', 'series'] as const for (const reason of reasons) { const windowedSystem = reason === 'series' ? '# Original' : '# Windowed' @@ -967,7 +1360,13 @@ describe('built-in conversation node Definitions', () => { }), ], true) - expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8) + const before = snapshot(windowed) + const prompt = node(before, 'system-prompt') + const user = node(before, 'user') + if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete') + const stableOrder = [user.key, prompt.key] + expect(prompt.anchorSeq).toBe(8) + expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) windowed.prepend([ at(1, 'turn/start', { turn: 1 }), @@ -985,7 +1384,9 @@ describe('built-in conversation node Definitions', () => { const candidate = restored.nodes.get(key) return candidate?.kind === 'system-prompt' ? [candidate] : [] }) - expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5]) + expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8]) + expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8) + expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder) } }) diff --git a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx index a9f37c54bf..3da452f418 100644 --- a/packages/client/ui-chat/tests/selection-survival.client.spec.tsx +++ b/packages/client/ui-chat/tests/selection-survival.client.spec.tsx @@ -73,7 +73,7 @@ describe('Chat selection survives on its store seat', () => { await b.runtime.sessions.add({ id: 's1' }) const reborn = storeFor(b, 'conversation.view', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts new file mode 100644 index 0000000000..b0781e1edf --- /dev/null +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import type { ChatSettings } from '../src/chat-settings.ts' +import { TranscriptViewPolicy } from '../src/client/transcript-view.ts' + +describe('TranscriptViewPolicy', () => { + it('defaults to Compact and publishes explicit choices before persistence settles', () => { + const host = stubSettingsScope() + const observed: string[] = [] + let current = (): string => 'unconstructed' + const scope: typeof host.scope = { + ...host.scope, + set: (field, value) => { + observed.push(`${field}=${String(value)}:${current()}`) + return host.scope.set(field, value) + }, + } + const policy = new TranscriptViewPolicy(scope) + 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') + }) + + it('adopts Host state and ignores identical writes', () => { + const host = stubSettingsScope() + const policy = new TranscriptViewPolicy(host.scope) + + host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true }) + expect(policy.mode.getSnapshot()).toBe('normal') + policy.setMode('normal') + expect(host.set).not.toHaveBeenCalled() + + host.publish({ value: { transcriptView: 'compact' }, revision: 2 }) + expect(policy.mode.getSnapshot()).toBe('compact') + }) + + it('adopts an accepted section standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true }) + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') + }) +}) 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 new file mode 100644 index 0000000000..f946a28f1a --- /dev/null +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +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 { en } from '../src/client/locale.ts' + +afterEach(cleanup) + +function emptySessions() { + return bindSnapshotSelector(createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + })) +} + +function emptyWorkspaces() { + return bindSnapshotSelector(createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + })) +} + +function noPendingInteraction() { + return bindSnapshotSelector(createSnapshotStore(new Map())) +} + +function mount(mode: 'normal' | 'compact' = 'compact') { + const source = createSnapshotStore(mode) + const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) + const props: TranscriptViewRowProps = { + useSessions: emptySessions(), + useSessionPendingInteraction: noPendingInteraction(), + useWorkspaces: emptyWorkspaces(), + useTranscriptView: bindSnapshotSelector(source), + setTranscriptView, + t: makeTranslate(en), + } + render() + return { setTranscriptView } +} + +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.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('selects Normal and follows the mirrored value', () => { + 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/ }) + fireEvent.click(trigger) + expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull() + }) +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index f819db101d..4d42320885 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -56,6 +56,9 @@ { "path": "../../session/session-stats" }, + { + "path": "../../settings/settings" + }, { "path": "../locale" }, @@ -80,6 +83,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-settings" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index ad7302d2c1..d2f8ccec8c 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -7,7 +7,7 @@ import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { @@ -103,6 +103,7 @@ async function bench(snapshot: ChatSnapshot) { const chat = createSnapshotStore(snapshot) const events = new ConversationEventRegistry(ctx) const views = new ConversationViewRegistry(ctx) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) ctx.provide('uiConversation', { events, views, 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 47c583f4de..964f83ad24 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:202', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:173', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:190', }, { key: 'conversation.chat.node', @@ -266,11 +266,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: ToolCallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: ToolCallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: ToolCallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: ToolCallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', 'RenderMessageImages', + 'TurnProcessOwnerProps', 'TurnTailOwnerProps', ], standardProps: [ @@ -287,7 +288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', hookContext: 'string', slotInject: 'ChatNodeTurnDataInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', @@ -303,6 +304,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat RetryNodeView key \'model-retry\'', 'client-ui-chat TurnErrorNodeView key \'turn-error\'', 'client-ui-chat TurnMaxTokensNodeView key \'turn-max-tokens\'', + 'client-ui-chat TurnProcessNodeView key \'turn-process\'', 'client-ui-chat TurnTailNodeView key \'turn-tail\'', 'client-ui-chat UnknownNodeView key \'unknown\'', 'client-ui-goal GoalCommandInputView key \'command-input\'', @@ -311,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:154', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:171', }, { key: 'conversation.chat.turnTail', @@ -356,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.composer', @@ -535,7 +537,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:208', }, { key: 'conversation.hero.agentPreset', @@ -1025,7 +1027,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:167', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:184', }, { key: 'conversation.session', @@ -1525,6 +1527,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-locale LanguageRow id \'language\'', 'client-ui-agent-preset AgentPresetRow id \'agent-preset\'', + 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', 'client-ui-theme AppearanceRow id \'appearance\'', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eb529f007..f7aec63d5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1961,6 +1961,10 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-chat: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2004,6 +2008,9 @@ importers: '@deepseek-ai/dsh-client-ui-session': specifier: workspace:^ version: link:../ui-session + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2031,6 +2038,9 @@ importers: '@deepseek-ai/dsh-session-stats': specifier: workspace:^ version: link:../../session/session-stats + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index 8df10012df..32f481d4ff 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -16,6 +16,9 @@ - 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": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 0fa28bef4b..f58131af8c 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -20,6 +20,9 @@ - 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. {{clock}}" - button "Copy": - img +- button "3 tool calls" [expanded]: + - text: 3 tool calls + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img @@ -70,10 +73,9 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- button "Context injection cordis-host-runner": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection cordis-host-runner - paragraph: The Cordis Plugin is running. - button "Copy": - img @@ -86,6 +88,9 @@ - text: {{clock}} Ran for {{duration}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}} - button "Copy": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - img - text: Stop Cordis Plugin snap-1 - button "Inspect" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md new file mode 100644 index 0000000000..72988cae07 --- /dev/null +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -0,0 +1,53 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled." +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index f654d948bf..0c4683b73d 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -16,14 +16,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to reply with a single word. Let me comply.": - - img - - img - - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE - button "Copy": - img diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md new file mode 100644 index 0000000000..0c5ebce2fb --- /dev/null +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -0,0 +1,53 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared." +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 4b108200bf..1918a0e3ca 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -16,14 +16,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to reply with a single word. Let me comply.": - - img - - img - - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE - button "Copy": - img diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md new file mode 100644 index 0000000000..92222fbcd8 --- /dev/null +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -0,0 +1,57 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the bash tool to" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to run a simple bash command and reply with \"DONE\".": + - img + - img + - text: Think The user wants me to run a simple bash command and reply with "DONE". +- button "Bash Echo the test string": + - img + - img + - text: Bash Echo the test string +- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": + - img + - img + - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". +- paragraph: DONE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 24717b214d..9085361adc 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -16,22 +16,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call": + - text: 1 tool call - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - - img - - img - - text: Think The user wants me to run a simple bash command and reply with "DONE". -- button "Bash Echo the test string": - - img - - img - - text: Bash Echo the test string -- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - - img - - img - - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE - button "Copy": - img diff --git a/snapshots/web/goal-multi-turn-actions/session.jsonl b/snapshots/web/goal-multi-turn-actions/session.jsonl index 436768541a..4b87d11dfb 100644 --- a/snapshots/web/goal-multi-turn-actions/session.jsonl +++ b/snapshots/web/goal-multi-turn-actions/session.jsonl @@ -26,7 +26,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"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.\"\n\nSo this is round 1 of 2. I need to output the file structure of a random package. Let me check the workspace to see if there's anything relevant — maybe there are packages installed or a specific context.\n\nLet me first check the current working directory and see what's there.\n\nThe instruction says: after finishing a turn, output the content directly and stop. So for this round, I should pick a random package and output its file structure. The system will open another turn for the second one.\n\nLet me look at the workspace to understand the environment."},{"type":"tool-call","id":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","arguments":"{\"command\":\"find packages -mindepth 2 -maxdepth 2 -type d | sort\",\"description\":\"List package directories deterministically\"}"},{"type":"tool-call","id":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":7893,"outputTokens":297,"cacheReadTokens":0,"reasoningTokens":213}},"sourceEventSeqs":[14,15,16,17,18,19,20,21,22,23,24],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_IVvwEvXl7JJoIHrqvlDi1572","name":"bash","arguments":"{\"command\":\"find packages -mindepth 2 -maxdepth 2 -type d | sort\",\"description\":\"List package directories deterministically\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_IVvwEvXl7JJoIHrqvlDi1572"},"content":[{"type":"tool-result","toolCallId":"call_00_IVvwEvXl7JJoIHrqvlDi1572","content":[{"type":"text","text":"packages/client/ui-conversation\npackages/context/session-reference\npackages/llm/token-meter\npackages/skill/skill-filesystem\n"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_IVvwEvXl7JJoIHrqvlDi1572"},"content":[{"type":"tool-result","toolCallId":"call_00_IVvwEvXl7JJoIHrqvlDi1572","content":[{"type":"text","text":"packages/client/ui-chat\npackages/client/ui-conversation\npackages/context/session-reference\npackages/llm/token-meter\npackages/skill/skill-filesystem\n"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[26],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_hlqFt1P4CYy1jb078Ke71690","name":"get_goal","arguments":"{}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_hlqFt1P4CYy1jb078Ke71690"},"content":[{"type":"tool-result","toolCallId":"call_01_hlqFt1P4CYy1jb078Ke71690","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":1,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"active\",\"roundsStarted\":1,\"maxGoalRounds\":256},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[28],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -41,7 +41,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"There's a `packages` directory. Let me look inside to see what packages are available."},{"type":"tool-call","id":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","arguments":"{\"command\":\"find packages -mindepth 1 -maxdepth 1 -type d | sed 's#^packages/##' | sort && echo '---' && find packages -maxdepth 2 -type d | sort\",\"description\":\"List packages directory contents\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":363,"outputTokens":99,"cacheReadTokens":8064,"reasoningTokens":20}},"sourceEventSeqs":[32,33,34,35,36,37,38,39],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_XlYaTHoKielffCybfcXb9253","name":"bash","arguments":"{\"command\":\"find packages -mindepth 1 -maxdepth 1 -type d | sed 's#^packages/##' | sort && echo '---' && find packages -maxdepth 2 -type d | sort\",\"description\":\"List packages directory contents\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_XlYaTHoKielffCybfcXb9253"},"content":[{"type":"tool-result","toolCallId":"call_00_XlYaTHoKielffCybfcXb9253","content":[{"type":"text","text":"client\ncontext\nllm\nskill\n---\npackages\npackages/client\npackages/client/ui-conversation\npackages/context\npackages/context/session-reference\npackages/llm\npackages/llm/token-meter\npackages/skill\npackages/skill/skill-filesystem\n"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_XlYaTHoKielffCybfcXb9253"},"content":[{"type":"tool-result","toolCallId":"call_00_XlYaTHoKielffCybfcXb9253","content":[{"type":"text","text":"client\ncontext\nllm\nskill\n---\npackages\npackages/client\npackages/client/ui-chat\npackages/client/ui-conversation\npackages/context\npackages/context/session-reference\npackages/llm\npackages/llm/token-meter\npackages/skill\npackages/skill/skill-filesystem\n"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md new file mode 100644 index 0000000000..d943f181c2 --- /dev/null +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -0,0 +1,232 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - 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"': + - img + - img + - text: "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": + - img + - img + - text: System prompt +- button "6 tool calls" [expanded]: + - text: 6 tool calls + - img +- button "Context injection goal": + - img + - img + - text: Context injection goal +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- '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.\""': + - img + - img + - text: "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 "Bash List package directories deterministically": + - img + - img + - text: Bash List package directories deterministically +- 'button "Tool call get_goal · {}"': + - img + - img + - text: "Tool call get_goal · {}" +- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."': + - img + - img + - text: "Think There's a `packages` directory. Let me look inside to see what packages are available." +- button "Bash List packages directory contents": + - img + - img + - text: Bash List packages directory contents +- button "Think There are 4 packages:": + - img + - img + - text: "Think There are 4 packages:" +- button "Failed Bash Randomly pick one package directory": + - img + - text: 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."': + - img + - img + - text: "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 "Bash Randomly select one package with Python": + - img + - img + - text: 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."': + - img + - img + - text: "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 "Bash List all files in session-reference package": + - img + - img + - text: 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."': + - img + - img + - text: "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." +- paragraph: + - strong: Turn 1 / 2 + - text: — 随机选中的包: + - code: packages/context/session-reference + - text: 📦 +- button "Copy" +- code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts +- button "Copy" +- code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试" +- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "System prompt": + - img + - img + - text: System prompt +- button "5 tool calls · 2 messages" [expanded]: + - text: 5 tool calls · 2 messages + - img +- button "Context injection goal": + - img + - img + - text: 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."': + - img + - img + - text: "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 "Bash Randomly select another package with Python": + - img + - img + - text: 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."': + - img + - img + - text: "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 "Bash Re-roll excluding the package from turn 1": + - img + - img + - text: Bash Re-roll excluding the package from turn 1 +- button "Bash List all files in token-meter package": + - img + - img + - text: 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."': + - img + - img + - text: "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." +- paragraph: + - strong: Turn 2 / 2 + - text: — 随机选中的包: + - code: packages/llm/token-meter + - text: 📦 +- button "Copy" +- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts +- button "Copy" +- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" +- paragraph: + - text: 两个 turn 都完成了(turn 1: + - code: session-reference + - text: ,turn 2: + - code: token-meter + - text: )。这一轮到此结束。 +- 'button "Tool call get_goal · {}"': + - img + - img + - text: "Tool call get_goal · {}" +- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 +- button "Tool call update_goal · goal-{{uuid}}": + - img + - img + - text: Tool call update_goal · goal-{{uuid}} +- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"': + - img + - img + - text: "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" +- paragraph: 两个 turn 都完成了 ✅ +- paragraph: + - strong: 总结: +- list: + - listitem: + - strong: Turn 1 + - text: — 随机选中 + - code: packages/context/session-reference + - text: ,输出了文件结构: + - button "Copy" + - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts + - listitem: + - strong: Turn 2 + - text: — 随机选中 + - code: packages/llm/token-meter + - text: ,输出了文件结构: + - button "Copy" + - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts +- paragraph: + - strong: 验证方式: + - text: 两个包的随机选择都通过 + - code: python3 random.choice + - text: 从工作区的 + - code: packages/ + - text: 目录下实际枚举的 4 个候选包中抽取( + - code: ui-conversation + - text: 、 + - code: session-reference + - text: 、 + - code: token-meter + - text: 、 + - code: skill-filesystem + - text: ),文件结构则用 + - code: find + - text: 实际列出文件后整理输出,均已排除 + - code: node_modules + - text: 和 + - code: .git + - text: 。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。 +- paragraph: + - strong: 说明: + - text: 本轮没有产生任何文件修改或提交——任务本身就是"输出文件结构",所有产物都在上面的消息文本里。 +- paragraph: + - text: 如果你还想看剩下两个包( + - code: ui-conversation + - text: 、 + - code: skill-filesystem + - text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。 +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Back to bottom": + - img +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "9% of context used" +- button "Send message" [disabled] +- text: 2 turns · 12 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 91% Input 113K tok · Output 2.4K tok diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index bd50420df2..8b0b4737dc 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -21,61 +21,9 @@ - img - img - text: System prompt -- button "Context injection goal": +- button "6 tool calls": + - text: 6 tool calls - img - - img - - text: Context injection goal -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- '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.\""': - - img - - img - - text: "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 "Bash List package directories deterministically": - - img - - img - - text: Bash List package directories deterministically -- 'button "Tool call get_goal · {}"': - - img - - img - - text: "Tool call get_goal · {}" -- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."': - - img - - img - - text: "Think There's a `packages` directory. Let me look inside to see what packages are available." -- button "Bash List packages directory contents": - - img - - img - - text: Bash List packages directory contents -- button "Think There are 4 packages:": - - img - - img - - text: "Think There are 4 packages:" -- button "Failed Bash Randomly pick one package directory": - - img - - text: 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."': - - img - - img - - text: "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 "Bash Randomly select one package with Python": - - img - - img - - text: 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."': - - img - - img - - text: "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 "Bash List all files in session-reference package": - - img - - img - - text: 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."': - - img - - img - - text: "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." - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -99,62 +47,9 @@ - img - img - text: System prompt -- button "Context injection goal": +- button "5 tool calls · 2 messages": + - text: 5 tool calls · 2 messages - img - - img - - text: 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."': - - img - - img - - text: "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 "Bash Randomly select another package with Python": - - img - - img - - text: 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."': - - img - - img - - text: "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 "Bash Re-roll excluding the package from turn 1": - - img - - img - - text: Bash Re-roll excluding the package from turn 1 -- button "Bash List all files in token-meter package": - - img - - img - - text: 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."': - - img - - img - - text: "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." -- paragraph: - - strong: Turn 2 / 2 - - text: — 随机选中的包: - - code: packages/llm/token-meter - - text: 📦 -- button "Copy" -- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts -- button "Copy" -- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试" -- paragraph: - - text: 两个 turn 都完成了(turn 1: - - code: session-reference - - text: ,turn 2: - - code: token-meter - - text: )。这一轮到此结束。 -- 'button "Tool call get_goal · {}"': - - img - - img - - text: "Tool call get_goal · {}" -- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 -- button "Tool call update_goal · goal-{{uuid}}": - - img - - img - - text: Tool call update_goal · goal-{{uuid}} -- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"': - - img - - img - - text: "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md new file mode 100644 index 0000000000..31ff786841 --- /dev/null +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -0,0 +1,49 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "8% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 74122b000b..8031b8de59 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -16,14 +16,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to reply with a single word. Let me comply.": - - img - - img - - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE - button "Copy": - img diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md new file mode 100644 index 0000000000..c64e864ff8 --- /dev/null +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -0,0 +1,45 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- text: Stopped +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 0a189478b8..c54a4aee60 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -16,10 +16,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md new file mode 100644 index 0000000000..04c061962c --- /dev/null +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -0,0 +1,51 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- 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.": + - img + - img + - text: 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. +- 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": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index f1f6352fb4..850d151cb9 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -16,16 +16,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- 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.": - - img - - img - - text: 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. - 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": - img diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 73d9d436da..2d104f8e54 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -16,6 +16,9 @@ - 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": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded]: - img - text: Bash printf 'MINIMAL_BASH_CARD_OK\n' diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md new file mode 100644 index 0000000000..c6b3239426 --- /dev/null +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -0,0 +1,67 @@ +- banner: + - navigation "Session hierarchy": + - 'button "Plan a small change: add" [disabled]' + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- img +- text: plan Plan mode on. Use /plan off to leave. +- button "System prompt": + - img + - img + - text: 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}}" +- button "Copy": + - img +- button "1 tool call · 1 message" [expanded]: + - text: 1 tool call · 1 message + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- '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."': + - img + - img + - text: "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." +- 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"': + - img + - img + - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI" +- button "System prompt": + - img + - img + - text: 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."': + - img + - img + - text: "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." +- paragraph: DONE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "4% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 10.2K tok · Output 346 tok diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index c2791f6a6f..9c6a0e09e5 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -18,30 +18,13 @@ - 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}}" - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call · 1 message": + - text: 1 tool call · 1 message - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- '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."': - - img - - img - - text: "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." -- 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"': - - img - - img - - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI" - button "System prompt": - img - img - text: 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."': - - img - - img - - text: "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." - paragraph: DONE - button "Copy": - img diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md new file mode 100644 index 0000000000..da262e8712 --- /dev/null +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -0,0 +1,59 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": + - img + - img + - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. +- button "Ask question 1/1 answered" [expanded]: + - img + - text: Ask question 1/1 answered +- 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.": + - img + - img + - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. +- paragraph: DONE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "3% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 95% Input 8.6K tok · Output 180 tok diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index d9dcc0cadc..67524dbc79 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -16,24 +16,9 @@ - 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": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call": + - text: 1 tool call - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - - img - - img - - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. -- button "Ask question 1/1 answered" [expanded]: - - img - - text: Ask question 1/1 answered -- 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.": - - img - - img - - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE - button "Copy": - img diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md new file mode 100644 index 0000000000..d92f39fc8a --- /dev/null +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -0,0 +1,64 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- text: Stopped +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} +- button "2 queued messages" [expanded] +- list: + - listitem: + - text: Edited queue item + - button "Edit queued message": + - img + - tooltip "Edit queued message" + - button "Remove queued message": + - img + - button "Steer queued message" [disabled]: + - img + - listitem: + - text: Queue item preserved after stop + - button "Edit queued message": + - img + - button "Remove queued message": + - img + - button "Steer queued message" [disabled]: + - img +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index ee54e52f13..66769e0488 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -16,10 +16,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - text: Stopped - button "Copy": diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index a5024197d8..c049c42fe6 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -17,6 +17,9 @@ - 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. 7/25 {{clock}}" - button "Copy": - img +- button "2 tool calls" [expanded]: + - text: 2 tool calls + - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 06d9da3475..a24aaf78f0 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -17,6 +17,9 @@ - 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. 7/25 {{clock}}" - button "Copy": - img +- button "2 tool calls" [expanded]: + - text: 2 tool calls + - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md new file mode 100644 index 0000000000..38e1f2a261 --- /dev/null +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -0,0 +1,64 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" +- button "System prompt": + - img + - img + - text: 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. 7/25 {{clock}}" +- button "Copy": + - img +- button "2 tool calls" [expanded]: + - text: 2 tool calls + - img +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - 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.": + - img + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- paragraph: DONE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "compact Compacted 5 history items (~{{tokens}} tokens)" +- button "Context injection AGENTS.md": + - img + - img + - text: Context injection AGENTS.md +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 46e96f4b15..d03f428f5d 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -17,24 +17,9 @@ - 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. 7/25 {{clock}}" - button "Copy": - img -- 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 "2 tool calls": + - text: 2 tool calls - img - - img - - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button "Read a.txt": - - img - - img - - text: Read - - button "a.txt" -- button "Read b.txt": - - img - - img - - 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.": - - img - - img - - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE - button "Copy": - img diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 8426371014..ce5851b7e9 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -14,6 +14,9 @@ - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md new file mode 100644 index 0000000000..44e93167a3 --- /dev/null +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Ask question 1/1 answered": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- paragraph: Great, let's move forward. BANANA! +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 10 tok diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 0b866129f4..a650f1e211 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -16,14 +16,9 @@ - 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": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call": + - text: 1 tool call - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Ask question 1/1 answered": - - img - - img - - text: Ask question 1/1 answered - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md new file mode 100644 index 0000000000..71707d3523 --- /dev/null +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -0,0 +1,78 @@ +- banner: + - navigation "Session hierarchy": + - button "Ask a research subagent to" + - text: / + - 'button "Switch subagent: event-sourcing researcher"': event-sourcing researcher + - button "1 subagent": + - text: 1 subagent + - img + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- navigation "Turn navigation": + - button "Jump to turn 1" + - button "Jump to turn 2" +- button "System prompt": + - img + - img + - text: System prompt +- text: Explain event sourcing in one sentence. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- 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.": + - img + - img + - text: 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. +- 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": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "System prompt": + - img + - img + - text: System prompt +- text: Now give the same explanation to a human reader. {{clock}} +- button "Copy": + - img +- button "Thought for a while" [expanded]: + - text: Thought for a while + - img +- 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.": + - img + - img + - text: 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. +- 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": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Custom"': Custom +- button "6% of context used" +- button "Send message" [disabled] +- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 7f09614d07..6474f5f98a 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -24,14 +24,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "Thought for a while": + - text: Thought for a while - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- 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.": - - img - - img - - text: 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. - 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": - img @@ -49,10 +44,9 @@ - text: Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- 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 "Thought for a while": + - text: Thought for a while - img - - img - - text: 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. - 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": - img diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md new file mode 100644 index 0000000000..fd12bf82ca --- /dev/null +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -0,0 +1,45 @@ +- banner: + - navigation "Session hierarchy": + - button "Begin your reply with the" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call · 1 message": + - text: 1 tool call · 1 message + - img +- paragraph: DONE +- button "Turn usage 15.8K tok · Cache hit 49.7%": + - img + - img + - text: Turn usage 15.8K tok · Cache hit 49.7% +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 50% Input 15.7K tok · Output 112 tok diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md new file mode 100644 index 0000000000..e313ab41fc --- /dev/null +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -0,0 +1,58 @@ +- banner: + - navigation "Session hierarchy": + - button "Begin your reply with the" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: 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": + - img +- button "1 tool call · 1 message" [expanded]: + - text: 1 tool call · 1 message + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- 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.": + - img + - img + - text: 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. +- paragraph: Reading the workspace now. +- button "Bash Print alpha to stdout": + - img + - img + - text: Bash Print alpha to stdout +- paragraph: DONE +- button "Turn usage 15.8K tok · Cache hit 49.7%": + - img + - img + - text: Turn usage 15.8K tok · Cache hit 49.7% +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 50% Input 15.7K tok · Output 112 tok diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 42cfbdaacd..a711875317 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -16,19 +16,9 @@ - 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": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call · 1 message": + - text: 1 tool call · 1 message - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- 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.": - - img - - img - - text: 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. -- paragraph: Reading the workspace now. -- button "Bash Print alpha to stdout": - - img - - img - - text: Bash Print alpha to stdout - paragraph: partial - text: Stopped - button "Copy": diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 9886eaac0a..ba9a3f5c1f 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -16,19 +16,9 @@ - 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": - img -- button "Context injection @deepseek-ai/dsh-system-prompt": +- button "1 tool call · 1 message": + - text: 1 tool call · 1 message - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- 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.": - - img - - img - - text: 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. -- paragraph: Reading the workspace now. -- button "Bash Print alpha to stdout": - - img - - img - - text: Bash Print alpha to stdout - paragraph: DONE - button "Turn usage 15.8K tok · Cache hit 49.7%" [expanded]: - img diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 26ffed2add..3b9f58931d 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -16,6 +16,9 @@ - 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": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 617a06bbf5..9e8037c6b3 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -5,6 +5,9 @@ - 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": - img +- button "1 tool call" [expanded]: + - text: 1 tool call + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img