Merge pull request #5983 from deepseek-harness/worktree-chatmisc
fix(chat): hide empty reasoning and improve command and file details
This commit is contained in:
commit
3daec753f8
65 files changed
+2311
-319
No files matched your search
+10
-10
@@ -9,11 +9,11 @@
|
||||
en: 9f34c53e647c69ca
|
||||
zh: 551776c7cc9618b5
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/decision:
|
||||
en: bdbb99c01b4c4db0
|
||||
zh: 570040471ad1f7f4
|
||||
en: 5fcc5a3de9f757cd
|
||||
zh: a693cefdb82784ee
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/decision/goals-and-non-goals:
|
||||
en: 7e7a937dc4fe1b18
|
||||
zh: 530f508b7e17b607
|
||||
en: 40aceb58d71303b5
|
||||
zh: 6954e41ae2fd1efd
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/terminology:
|
||||
en: 021106dd41176715
|
||||
zh: 131caff7bd37251b
|
||||
@@ -72,11 +72,11 @@
|
||||
en: f40f0a8ee8c055c0
|
||||
zh: 6957b7372956113c
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/generic-tool-row:
|
||||
en: 20ff82b092325c5c
|
||||
zh: 1afd5441a9816117
|
||||
en: d336c77d1b09f349
|
||||
zh: 6e613f9c9108058f
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/terminal-card:
|
||||
en: eab7f366df331b6e
|
||||
zh: d82499e2a982206f
|
||||
en: 492669c2134169b2
|
||||
zh: 0247a38df2dd8a83
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-card-model-design/diff-card:
|
||||
en: ea384415c94f82da
|
||||
zh: 5f146c6cb534e1db
|
||||
@@ -99,8 +99,8 @@
|
||||
en: e5b42aa311079bd7
|
||||
zh: ec4cf97037ece7e0
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/presentation-equivalence-matrix:
|
||||
en: 30915c26728eeafe
|
||||
zh: eea04dea7b4604a5
|
||||
en: 7974f20a6ac687ac
|
||||
zh: cb4c350325a6da67
|
||||
/agent-note-client-derived-presentation-from-raw-session-tool-events/client-extension-contract:
|
||||
en: 96619dfd5ef299cf
|
||||
zh: 253df98eb42ad75e
|
||||
|
||||
+8
-7
@@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner
|
||||
|
||||
## Decision
|
||||
|
||||
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md); all other presentation and ownership constraints remain.
|
||||
The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md); all other presentation and ownership constraints remain.
|
||||
|
||||
The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
|
||||
|
||||
@@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall
|
||||
| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
|
||||
| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
|
||||
| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
|
||||
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md) and [recorded tool details](../../../../packages/client/ui-tool/README.md) |
|
||||
| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../../../../packages/client/ui-tool/README.md), [recorded tool details](../../../../packages/client/ui-tool/README.md), and [background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) |
|
||||
| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
|
||||
|
||||
## Terminology
|
||||
@@ -294,15 +294,16 @@ Unknown tools continue to use `GenericToolCard` with the name, raw arguments, re
|
||||
|
||||
The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values.
|
||||
|
||||
<a id="terminal-card"></a>
|
||||
### Terminal card
|
||||
|
||||
The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`.
|
||||
The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, and Session cwd, independently of `parentCallId`. [Background Shell command display](../bug-fix/2026-10-10-background-shell-command-display.md) governs explicit background `bash`/`pwsh` launches; the remaining ownership and fallback rules below stay in force.
|
||||
|
||||
| Input | Preserved result |
|
||||
|---|---|
|
||||
| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
|
||||
| successful standard foreground call | terminal output, exit code/signal, and settled state presentation |
|
||||
| `run_in_background:true` | Generic row and raw result |
|
||||
| explicit background standard `bash`/`pwsh` | original `background` flag with call `running` at Start; acknowledgement clears `running` and retains a static command without output or job status |
|
||||
| tool execution error | Generic IN/OUT and error summary |
|
||||
| running persistent `bash`/`pwsh` | terminal prompt |
|
||||
| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
|
||||
@@ -310,7 +311,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n
|
||||
| background/error `terminal_send` | Generic result |
|
||||
| PTC dispatch child | same terminal eligibility and fallback rules as a root call |
|
||||
|
||||
Standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
|
||||
Foreground standard shell results parse trailing `[exit code: N]` and `[killed by signal: X]` markers; explicit background launch acknowledgements do not enter output or exit-status parsing. A final recognized spill-policy notice selects Generic output instead: expandable in shell rows and raw in Details, because the exit marker may be displaced or omitted. A parsed marker is removed from the terminal body; timeout, sandbox denial, and markers without a pill remain in the body.
|
||||
|
||||
Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
|
||||
|
||||
@@ -318,7 +319,7 @@ For `terminal_send`, non-empty input and the session id remain verbatim tool dat
|
||||
|
||||
Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
|
||||
|
||||
`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
|
||||
Outside the explicit background command-only presentation, `TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
|
||||
|
||||
### Diff card
|
||||
|
||||
@@ -412,7 +413,7 @@ The scenario does not import Host tool packages to compute page presentation and
|
||||
| orphan result | callId title and Generic output |
|
||||
| interrupted call | warning/stopped state |
|
||||
| foreground bash/pwsh | current terminal prompt, body, cwd, and state |
|
||||
| background/error bash/pwsh | current Generic IN/OUT |
|
||||
| explicit background bash/pwsh | terminal Start and static command-only acknowledgement; launch errors retain Generic IN/OUT |
|
||||
| persistent shell | current running terminal and settled Generic form |
|
||||
| terminal_send | current foreground terminal and background/error Generic form |
|
||||
| write/edit | current intended/applied diff and error fallback |
|
||||
|
||||
+8
-7
@@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的
|
||||
|
||||
## Decision
|
||||
|
||||
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md);其他展示与所有权约束全部保留。
|
||||
下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md);其他展示与所有权约束全部保留。
|
||||
|
||||
Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
|
||||
|
||||
@@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa
|
||||
| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
|
||||
| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
|
||||
| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
|
||||
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)和[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)除外 |
|
||||
| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../../../../packages/client/ui-tool/README.zh.md)、[记录结果的工具详情](../../../../packages/client/ui-tool/README.zh.md)和[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)除外 |
|
||||
| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
|
||||
|
||||
## 术语
|
||||
@@ -294,15 +294,16 @@ Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必
|
||||
|
||||
Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。
|
||||
|
||||
<a id="terminal-card"></a>
|
||||
### Terminal 卡片
|
||||
|
||||
Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。
|
||||
Client terminal model 从工具名称、调用参数、结果 content、error 与 Session cwd 派生现有 `TerminalBlock` props,不依赖 `parentCallId`。[后台 Shell 命令展示](../bug-fix/2026-10-10-background-shell-command-display.zh.md)拥有主动后台 `bash`/`pwsh` 启动的展示决定;下述其余所有权和 fallback 规则继续有效。
|
||||
|
||||
| 输入 | 保持的结果 |
|
||||
|---|---|
|
||||
| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
|
||||
| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 |
|
||||
| `run_in_background:true` | Generic 行与原始结果 |
|
||||
| 标准 `bash`/`pwsh` 主动后台调用 | Start 使用原 `background` 标志和调用 `running`;回执清除 `running` 后保留静态命令,不显示输出或任务状态 |
|
||||
| 工具执行 error | Generic IN/OUT 与错误摘要 |
|
||||
| persistent `bash`/`pwsh` running | terminal prompt |
|
||||
| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
|
||||
@@ -310,7 +311,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
|
||||
| `terminal_send` background/error | Generic 结果 |
|
||||
| PTC dispatch child | 与根调用相同的 terminal 适用规则与 fallback 规则 |
|
||||
|
||||
标准 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
|
||||
标准前台 shell 结果解析末尾 `[exit code: N]` 与 `[killed by signal: X]`;主动后台启动回执不进入输出或退出状态解析。末尾已识别的 spill 策略提示会改用 Generic 输出:在 shell 行中可展开,在 Details 中显示原文,因为退出标记可能被移位或省略。已解析的 marker 从 terminal 正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
|
||||
|
||||
调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
|
||||
|
||||
@@ -318,7 +319,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error
|
||||
|
||||
同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
|
||||
|
||||
TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
|
||||
除主动后台的仅命令展示外,TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本保持不变。
|
||||
|
||||
### Diff 卡片
|
||||
|
||||
@@ -412,7 +413,7 @@ Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/resul
|
||||
| orphan result | callId 标题与 Generic output |
|
||||
| interrupted call | warning/stopped 状态 |
|
||||
| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 |
|
||||
| bash/pwsh background/error | 当前 Generic IN/OUT |
|
||||
| bash/pwsh 主动后台调用 | terminal Start 与静态仅命令回执展示;启动错误保留 Generic IN/OUT |
|
||||
| persistent shell | 当前 running terminal、settled Generic |
|
||||
| terminal_send | 当前前台 terminal、后台/error Generic |
|
||||
| write/edit | 当前 intended/applied diff 与 error fallback |
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-10-background-shell-command-display.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md
|
||||
/agent-note-separate-background-shell-command-display-from-job-state:
|
||||
en: c531a4b2eb444a12
|
||||
zh: 7c216f829d2e5168
|
||||
/agent-note-separate-background-shell-command-display-from-job-state/problem:
|
||||
en: 116454bc385c0cc3
|
||||
zh: 241905c37c22cb45
|
||||
/agent-note-separate-background-shell-command-display-from-job-state/decision:
|
||||
en: ca05afff8b542979
|
||||
zh: ccb19c8709718ca0
|
||||
/agent-note-separate-background-shell-command-display-from-job-state/alternatives-considered:
|
||||
en: 573a8d2e99de14e6
|
||||
zh: 1e7767c951672b42
|
||||
/agent-note-separate-background-shell-command-display-from-job-state/consequences:
|
||||
en: 15b6456f87cfeaa2
|
||||
zh: 5fec862a0180dc55
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Separate background Shell command display from job state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-10-background-shell-command-display.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An explicit background Shell call already carries its complete command and working directory at Start, but the Tool card shows generic argument JSON rather than the ordinary Shell command area. The original call returns a launch acknowledgement; the background result arrives through a separate completion notice or `job_output` call. Those messages do not directly carry the original Bash callId, so the acknowledgement cannot establish execution completion, and the original call's state cannot represent the job's live state.
|
||||
|
||||
## Decision
|
||||
|
||||
Valid explicit background `bash`/`pwsh` calls reuse the existing terminal command presentation. Before Start returns, the card indicates only that the launch call is in progress. After a normal launch acknowledgement, the same Tool card retains its command, working directory, wrapping, and disclosure state, and becomes a static command-only display without a run-state dot, exit code, empty-output placeholder, or continuing running animation. Launch failures retain the existing raw-argument and error-result presentation; Inspect still exposes the complete arguments and launch acknowledgement.
|
||||
|
||||
The terminal component receives the original call's `background` flag from Start and combines it with the call's `running` value: `background=true` and `running=true` mean that the launch call is still in progress. When the acknowledgement arrives, `background` stays unchanged and `running=false` leaves a static command; it does not describe the background job's running state. This introduces no derived presentation parameter or job state, and does not keep `running=true` indefinitely to pretend that the command is still executing. Ordinary foreground calls, persistent Shell calls, and `terminal_send` retain their default behavior; the existing Copy button's output-copy semantics do not change.
|
||||
|
||||
This decision covers explicit background launches only, not timeout promotion. It adds no job subscription, polling, output assembly, or callId/jobId index. Later completion notices and `job_output` keep their independent presentation, while the header's job panel owns live job information. The Tool card does not receive the final output, and Host interfaces, execution, and the Session log remain unchanged.
|
||||
|
||||
The [Client tool-presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md#terminal-card) still governs raw-event derivation and business ownership. This note supersedes only its generic-body rule for explicit background Shell calls, not background `terminal_send`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Parse the launch acknowledgement as an ordinary completed-command result.** The acknowledgement contains no command exit result and can therefore produce a false success indicator.
|
||||
|
||||
**Retain Start's running state indefinitely.** The original Tool call has returned, and without a job subscription the card cannot establish that the background job is still running.
|
||||
|
||||
**Match subsequent job output back to the original Tool.** This requires a reliable jobId-to-callId association and rules for incremental reads, missing history, and duplicate presentation. None is a prerequisite for displaying the existing command, so they remain outside this decision.
|
||||
|
||||
**Keep generic argument JSON as the only presentation.** The data remains available, but the card does not reuse the familiar Shell command presentation and retains an unnecessary difference between foreground and background calls.
|
||||
|
||||
## Consequences
|
||||
|
||||
Users can inspect the launch command in its original Tool card but cannot infer whether the background job is currently running, successful, or failed. Final state and output remain in the job panel, completion notices, and independent read calls. Component and assembly regressions cover disclosure preservation from Start to acknowledgement, absence of false state and empty output, launch failures, root/child calls, and unchanged foreground and `terminal_send` behavior; assembly-regression event cases use explicitly identified constructed data.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 主动后台 Shell 的命令展示与任务状态分离
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-10-background-shell-command-display.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
主动后台 Shell 在 Start 时已经具有完整 command 和工作目录,但 Tool 卡片使用通用参数 JSON,而不是普通 Shell 的命令区域。原调用返回的是启动回执,后台最终结果通过另一条完成通知或 `job_output` 调用出现;这些消息没有直接携带原 Bash callId,不能把启动回执当成执行完成,也不能用原调用状态代表任务实时状态。
|
||||
|
||||
## Decision
|
||||
|
||||
合法的主动后台 `bash`/`pwsh` 复用现有终端命令展示。Start 尚未返回时只表达启动调用正在进行;正常启动回执到达后,同一 Tool 卡片保留 command、工作目录、软换行及展开状态,切换为静态的仅命令展示,不显示执行状态点、退出码、空输出提示或持续运行动画。启动错误继续使用现有原始参数和错误结果展示;完整参数与启动回执仍可通过 Inspect 查看。
|
||||
|
||||
终端组件从 Start 起接收原调用的 `background` 标志,并结合调用的 `running` 决定展示:`background=true`、`running=true` 表示启动调用尚在进行;回执到达后 `background` 不变、`running=false`,只保留静态命令,不代表后台任务的运行状态。不新增派生展示参数或任务状态,也不以永久 `running=true` 伪造命令仍在执行。普通前台、持久 Shell 和 `terminal_send` 的默认行为不变;不改变原有 Copy 按钮的输出复制语义。
|
||||
|
||||
本次只处理主动后台启动,不修改超时转后台。没有 job 订阅、轮询、结果拼接或 callId/jobId 索引;后续完成通知和 `job_output` 保持原来的独立展示,标题栏任务面板继续拥有实时任务信息。Tool 卡片不回填最终输出,不修改 Host 接口、执行器或 Session 日志。
|
||||
|
||||
[Client 工具展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md#terminal-card)的原始事件派生与业务归属保持有效;本文仅替代其主动后台 Shell 使用通用正文的条目,不改变后台 `terminal_send`。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**直接按普通已结束命令解析启动回执。** 回执没有命令退出结果,容易显示虚假的成功状态。
|
||||
|
||||
**永久保留 Start 的 running 状态。** 原 Tool 调用已返回,且不订阅任务状态,无法保证后台任务仍在运行。
|
||||
|
||||
**将后续 job 输出匹配回原 Tool。** 需要可靠的 jobId 与原 callId 关联,还要定义分次读取、缺失历史和重复展示的处理;这些不是展示已有 command 的前提,本次不做。
|
||||
|
||||
**继续只展示通用参数 JSON。** 数据虽然存在,但没有复用用户熟悉的 Shell 命令展示,保留了后台与前台的不必要差异。
|
||||
|
||||
## Consequences
|
||||
|
||||
用户能在原 Tool 卡片查看启动命令,但不能据此判断后台任务当前成功、失败或运行中。最终状态和输出仍在任务面板、完成通知及独立读取调用中查看。组件与装配回归覆盖 Start→回执的展开保持、无虚假状态与空输出、启动失败、root/child、普通前台及 `terminal_send` 不变;装配回归的事件用例使用明确标识的构造数据。
|
||||
@@ -6,15 +6,15 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Page, Route } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { ToolCallId, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
@@ -28,7 +28,7 @@ import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
||||
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
|
||||
const TOOL_SESSION_ID = SessionId('chat-scroll-tool-e2e')
|
||||
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
|
||||
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
|
||||
const REPLAY_CONTEXT_WINDOW = 10_000_000
|
||||
@@ -47,7 +47,7 @@ const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
||||
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
||||
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
|
||||
const RAIL_SESSION_ID = 'chat-scroll-rail-e2e'
|
||||
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
|
||||
const FLING_SESSION_ID = SessionId('chat-scroll-fling-e2e')
|
||||
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
|
||||
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
|
||||
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
|
||||
@@ -74,6 +74,22 @@ const INPUTS_FIXTURE = createChatScrollFixture({
|
||||
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
|
||||
})
|
||||
|
||||
interface FoldMotionSample {
|
||||
readonly t: number
|
||||
readonly ordinaryGrowth: boolean
|
||||
readonly top: number
|
||||
readonly bodyY: number
|
||||
readonly earlierY: number
|
||||
readonly messageY: number | null
|
||||
readonly stepY: number | null
|
||||
readonly flowHeight: number
|
||||
readonly height: number
|
||||
readonly spacer: number
|
||||
readonly folding: boolean
|
||||
readonly raceApplied: boolean
|
||||
readonly composerTop: number
|
||||
}
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
readonly scrollTop: number
|
||||
@@ -525,115 +541,356 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('merges a large process fold with new-input following without returning downward', async () => {
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'FOLD', title: 'CHAT_FOLD_INPUT', turns: 8 })
|
||||
const input = 'CHAT_FOLD_NEXT_INPUT'
|
||||
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/fold-input-e2e/', import.meta.url))
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const evidence = await mkdtemp(join(artifactRoot, 'run-'))
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-fold-input-motion',
|
||||
replay: [replayEntry(textStream('CHAT_FOLD_REPLY', 'CHAT_FOLD_DONE', 2))],
|
||||
seeds: [{ fixture, id: 'chat-fold-input-e2e' }],
|
||||
}, async (world) => {
|
||||
const { page } = world
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await openSeed(page, fixture, fixture.markers.assistant(8))
|
||||
await openSettings(page, 'en')
|
||||
await page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'On completion', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
for (const index of [1, 2]) {
|
||||
const row = page.locator(`[data-chat-call-id="chat-scroll-008-${index}"] [data-sample="bash"]`)
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
for (const scenario of [
|
||||
{ name: 'body-only', bodyParagraphs: 32, inputLines: 1, reduced: false },
|
||||
{ name: 'visible-steps', bodyParagraphs: 2, inputLines: 1, reduced: false },
|
||||
{ name: 'large-input', bodyParagraphs: 2, inputLines: 18, reduced: false },
|
||||
{ name: 'reduced-motion', bodyParagraphs: 2, inputLines: 1, reduced: true },
|
||||
{ name: 'cancel-all-during-fold', bodyParagraphs: 2, inputLines: 1, reduced: false },
|
||||
{ name: 'content-growth-during-fold', bodyParagraphs: 32, inputLines: 1, reduced: false },
|
||||
]) {
|
||||
it.skipIf(MODE === 'record')(`uses only visible fold room in one upward movement: ${scenario.name}`, async () => {
|
||||
const base = createChatScrollFixture({ markerPrefix: 'VISIBLE_FOLD', title: `CHAT_VISIBLE_FOLD_${scenario.name}`, turns: 8 })
|
||||
const parsed = parseSeedFixture(base.log)
|
||||
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === 8)
|
||||
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = [
|
||||
...parsed.events.slice(0, boundary),
|
||||
{ type: 'turn/start', data: { turn: 8 } },
|
||||
{ type: 'user/message', data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'VISIBLE_FOLD_EARLIER_ANCHOR' }], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append' },
|
||||
]
|
||||
for (let step = 1; step <= 6; step++) {
|
||||
drafts.push({ type: 'step/start', data: { turn: 8, step } })
|
||||
drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn: 8, step, stream: [], message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: `VISIBLE_FOLD_STEP_${step}\n\n${Array.from({ length: 18 }, (_, line) =>
|
||||
`Reasoning stage ${step}, paragraph ${line}: retained work details explain this intermediate decision.`).join('\n\n')}` },
|
||||
{ type: 'text', text: `VISIBLE_FOLD_PROGRESS_${step}` },
|
||||
], source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
} })
|
||||
drafts.push({ type: 'step/end', data: { turn: 8, step } })
|
||||
}
|
||||
await wheelTranscript(page, -600)
|
||||
await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(page)
|
||||
const beforeHeight = await page.locator('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')
|
||||
.evaluate(element => element.getBoundingClientRect().height)
|
||||
const release = holdTextAfter(world, 0)
|
||||
const trace = page.evaluate(marker => new Promise<{
|
||||
readonly t: number
|
||||
readonly top: number
|
||||
readonly messageY: number
|
||||
readonly columnHeight: number
|
||||
readonly spacer: number
|
||||
readonly folding: boolean
|
||||
}[]>((resolve) => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
|
||||
const samples: { t: number; top: number; messageY: number; columnHeight: number; spacer: number; folding: boolean }[] = []
|
||||
let frame = 0
|
||||
let timer = 0
|
||||
const sample = (): void => {
|
||||
const message = Array.from(column.querySelectorAll<HTMLElement>('*'))
|
||||
.find(element => element.children.length === 0 && element.textContent === marker)
|
||||
if (message === undefined) return
|
||||
samples.push({ t: performance.now(), top: host.scrollTop, messageY: message.getBoundingClientRect().top,
|
||||
columnHeight: column.getBoundingClientRect().height,
|
||||
spacer: Number.parseFloat(host.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height ?? '') || 0,
|
||||
folding: column.querySelector('[data-chat-motion="collapse"]') !== null })
|
||||
const bodyMarker = 'VISIBLE_FOLD_BODY'
|
||||
drafts.push({ type: 'step/start', data: { turn: 8, step: 7 } })
|
||||
drafts.push({ type: 'assistant/message', surfaceOp: 'append', data: {
|
||||
turn: 8, step: 7, stream: [], message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: `${bodyMarker}\n\n${Array.from({ length: scenario.bodyParagraphs }, (_, line) =>
|
||||
`Final answer paragraph ${line}: this content remains after the preceding work details fold.`).join('\n\n')}` }],
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
} })
|
||||
drafts.push({ type: 'step/end', data: { turn: 8, step: 7 } },
|
||||
{ type: 'turn/end', data: { turn: 8, reason: { kind: 'completed' } } })
|
||||
const fixture: ChatScrollFixture = { ...base, log: [parsed.headerLine,
|
||||
...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n') }
|
||||
const input = Array.from({ length: scenario.inputLines }, (_, index) => `VISIBLE_FOLD_NEXT_${index}`).join('\n')
|
||||
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url))
|
||||
await mkdir(artifactRoot, { recursive: true })
|
||||
const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`))
|
||||
await withScrollWorld({
|
||||
failureShot: `visible-fold-${scenario.name}`,
|
||||
replay: [replayEntry(textStream('VISIBLE_FOLD_REPLY', 'VISIBLE_FOLD_DONE', 2))],
|
||||
seeds: [{ fixture, id: `visible-fold-${scenario.name}` }],
|
||||
}, async (world) => {
|
||||
const { page } = world
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await page.emulateMedia({ reducedMotion: scenario.reduced ? 'reduce' : 'no-preference' })
|
||||
await openSeed(page, fixture, bodyMarker)
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
|
||||
await timing.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
|
||||
const details = dialog.getByText('Work details', { exact: true }).locator('../..')
|
||||
await details.getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click()
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
const thoughts = page.locator('[data-chat-turn="8"] [data-variant="think"]')
|
||||
await expect.poll(() => thoughts.count()).toBe(6)
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const button = thoughts.nth(index).getByRole('button').first()
|
||||
if (await button.getAttribute('aria-expanded') !== 'true') await button.click()
|
||||
}
|
||||
const tick = (): void => {
|
||||
timer = window.setTimeout(() => { sample(); frame = requestAnimationFrame(tick) }, 0)
|
||||
}
|
||||
host.addEventListener('dsh-test-fold-stop', () => {
|
||||
cancelAnimationFrame(frame)
|
||||
window.clearTimeout(timer)
|
||||
sample()
|
||||
host.removeAttribute('data-fold-probe-ready')
|
||||
resolve(samples)
|
||||
}, { once: true })
|
||||
host.setAttribute('data-fold-probe-ready', '')
|
||||
frame = requestAnimationFrame(tick)
|
||||
}), input)
|
||||
await page.locator('[data-fold-probe-ready]').waitFor()
|
||||
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
||||
try {
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
settled = world.scaffold.whenTurnSettled(30_000)
|
||||
await page.getByText(input, { exact: true }).waitFor()
|
||||
await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
|
||||
await wheelTranscript(page, -600)
|
||||
await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(page)
|
||||
await nextPaint(page)
|
||||
} finally {
|
||||
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
|
||||
release()
|
||||
await settled
|
||||
}
|
||||
const samples = await trace
|
||||
expect(samples.length).toBeGreaterThan(4)
|
||||
const first = samples[0]!
|
||||
let minimum = first.messageY
|
||||
let reversal = 0
|
||||
let maximumScroll = first.top
|
||||
let viewportBacktrack = 0
|
||||
for (const sample of samples) {
|
||||
minimum = Math.min(minimum, sample.messageY)
|
||||
if (first.messageY - minimum > 4) reversal = Math.max(reversal, sample.messageY - minimum)
|
||||
maximumScroll = Math.max(maximumScroll, sample.top)
|
||||
viewportBacktrack = Math.max(viewportBacktrack, maximumScroll - sample.top)
|
||||
}
|
||||
const last = samples.at(-1)!
|
||||
const afterHeight = last.columnHeight
|
||||
await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, viewportBacktrack, samples }, null, 2))
|
||||
await page.screenshot({ path: join(evidence, 'settled.png') })
|
||||
console.log(`fold-input e2e: message reversal=${reversal}px, viewport backtrack=${viewportBacktrack}px, spacer=${last.spacer}px, evidence=${evidence}`)
|
||||
expect(beforeHeight - afterHeight).toBeGreaterThan(250)
|
||||
expect(first.messageY - minimum).toBeGreaterThan(40)
|
||||
expect(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect(viewportBacktrack).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect(Math.abs(last.spacer - (beforeHeight - afterHeight))).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expectBottom(page)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input)
|
||||
await nextPaint(page)
|
||||
if (scenario.name === 'visible-steps') {
|
||||
const offset = 20
|
||||
await thoughts.last().getByRole('button').first().evaluate((button, offset) => {
|
||||
const host = button.closest<HTMLElement>('[data-conversation-scroll]')!
|
||||
const group = button.closest<HTMLElement>('[data-chat-group-key]')!
|
||||
host.scrollTop += group.getBoundingClientRect().top - host.getBoundingClientRect().top - offset
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
}, offset)
|
||||
await nextPaint(page)
|
||||
}
|
||||
const before = await page.locator('[data-conversation-scroll]').evaluate((host) => {
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')!
|
||||
const body = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
||||
const groups = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-key]')]
|
||||
const candidates = [...host.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]')]
|
||||
.filter(element => !element.parentElement?.closest('[data-chat-turn="8"][data-chat-group-key], [data-chat-turn="8"][data-turn-process-member="true"]'))
|
||||
return { top: host.scrollTop, height: host.scrollHeight, viewportTop: viewport.top,
|
||||
removed: candidates.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const margin = Number.parseFloat(getComputedStyle(element).marginTop) || 0
|
||||
return { top: rect.top - viewport.top - margin, height: rect.height + margin }
|
||||
}),
|
||||
floor: Math.max(0, host.scrollHeight - host.clientHeight),
|
||||
viewport: host.clientHeight, visibleHeight: composer.getBoundingClientRect().top - viewport.top,
|
||||
bodyTop: body.getBoundingClientRect().top, bodyHeight: body.getBoundingClientRect().height,
|
||||
flowHeight: host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!.getBoundingClientRect().height,
|
||||
groupHeight: groups.reduce((sum, group) => sum + group.getBoundingClientRect().height, 0),
|
||||
visibleSteps: groups.reduce((sum, group) => {
|
||||
const rect = group.getBoundingClientRect()
|
||||
return sum + Math.max(0, Math.min(rect.bottom, composer.getBoundingClientRect().top) - Math.max(rect.top, viewport.top))
|
||||
}, 0) }
|
||||
})
|
||||
expect(before.groupHeight).toBeGreaterThan(before.visibleHeight * 3)
|
||||
if (scenario.bodyParagraphs === 32) expect(before.visibleSteps).toBe(0)
|
||||
else expect(before.visibleSteps).toBeGreaterThan(0)
|
||||
if (scenario.name === 'cancel-all-during-fold') {
|
||||
await openSettings(page, 'en')
|
||||
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
await settings.getByText('Work details', { exact: true }).locator('../..').getByRole('button').click()
|
||||
await page.getByRole('menuitem', { name: 'Verbose', exact: true }).waitFor()
|
||||
}
|
||||
const release = holdTextAfter(world, 0)
|
||||
const finishGate = Promise.withResolvers<undefined>()
|
||||
const finishBlocked = Promise.withResolvers<undefined>()
|
||||
const holdFinish = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
for await (const chunk of next()) {
|
||||
if (chunk.type === 'finish') {
|
||||
finishBlocked.resolve(undefined)
|
||||
await finishGate.promise
|
||||
}
|
||||
yield chunk
|
||||
}
|
||||
})
|
||||
const promptGate = Promise.withResolvers<undefined>()
|
||||
const promptBlocked = Promise.withResolvers<undefined>()
|
||||
const holdPrompt = async (route: Route): Promise<void> => {
|
||||
promptBlocked.resolve(undefined)
|
||||
await promptGate.promise
|
||||
await route.continue()
|
||||
}
|
||||
await page.route('**/api/session/prompt', holdPrompt)
|
||||
const trace = page.evaluate(({ marker, race }) => new Promise<FoldMotionSample[]>((resolveTrace) => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
|
||||
const body = [...column.querySelectorAll<HTMLElement>('[data-chat-turn="8"][data-chat-group-part="response"]')].at(-1)!
|
||||
const earlier = [...column.querySelectorAll<HTMLElement>('*')].find(element =>
|
||||
element.children.length === 0 && element.textContent === 'VISIBLE_FOLD_EARLIER_ANCHOR')!
|
||||
const samples: FoldMotionSample[] = []
|
||||
const visibleStep = [...column.querySelectorAll<HTMLElement>('[data-chat-turn="8"] [data-variant="think"] [role="button"]')].at(-1)
|
||||
let frame = 0
|
||||
let ordinaryGrowth = false
|
||||
let raceApplied = false
|
||||
let closingHeight: number | undefined
|
||||
const markGrowth = (): void => { ordinaryGrowth = true }
|
||||
host.addEventListener('dsh-test-fold-growth', markGrowth)
|
||||
const stepPosition = (): number | null => {
|
||||
if (visibleStep === undefined || !visibleStep.isConnected || visibleStep.closest('[hidden]') !== null) return null
|
||||
const rect = visibleStep.getBoundingClientRect()
|
||||
if (rect.height === 0 || rect.width === 0) return null
|
||||
const walker = document.createTreeWalker(visibleStep, NodeFilter.SHOW_TEXT)
|
||||
let textNode = walker.nextNode()
|
||||
while (textNode !== null && !(textNode.textContent ?? '').trim()) textNode = walker.nextNode()
|
||||
if (textNode === null) return null
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(textNode)
|
||||
const glyph = range.getBoundingClientRect()
|
||||
return glyph.height === 0 ? null : glyph.top
|
||||
}
|
||||
const sample = (): void => {
|
||||
const closing = [...column.querySelectorAll<HTMLElement>('[data-chat-motion="collapse"]')]
|
||||
.filter(element => element.parentElement?.closest('[data-chat-motion="collapse"]') === null)
|
||||
const height = closing.reduce((sum, element) => sum + element.getBoundingClientRect().height, 0)
|
||||
if (closing.length > 0) closingHeight ??= height
|
||||
if (!raceApplied && closingHeight !== undefined && height > 0 && height < closingHeight - 1) {
|
||||
if (race === 'cancel-all-during-fold') {
|
||||
const verbose = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(element => element.textContent?.trim() === 'Verbose')
|
||||
if (verbose !== undefined) {
|
||||
raceApplied = true
|
||||
verbose.click()
|
||||
}
|
||||
} else if (race === 'content-growth-during-fold') {
|
||||
// Controlled layout growth exercises ResizeObserver overlap, not Session-event delivery.
|
||||
raceApplied = true
|
||||
column.style.paddingBottom = '320px'
|
||||
}
|
||||
}
|
||||
const message = [...column.querySelectorAll<HTMLElement>('[data-submission-echo], [data-chat-flow-kind="user"]')]
|
||||
.find(element => element.textContent?.includes(marker))
|
||||
const step = stepPosition()
|
||||
samples.push({ t: performance.now(), ordinaryGrowth, top: host.scrollTop, bodyY: body.getBoundingClientRect().top,
|
||||
earlierY: earlier.getBoundingClientRect().top, messageY: message?.getBoundingClientRect().top ?? null,
|
||||
stepY: step,
|
||||
flowHeight: column.getBoundingClientRect().height, height: host.scrollHeight,
|
||||
spacer: host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!.getBoundingClientRect().height,
|
||||
folding: column.querySelector('[data-chat-motion="collapse"]') !== null, raceApplied,
|
||||
composerTop: host.querySelector<HTMLElement>('[data-composer-seat]')!.getBoundingClientRect().top })
|
||||
}
|
||||
const tick = (): void => { sample(); frame = requestAnimationFrame(tick) }
|
||||
host.addEventListener('dsh-test-fold-stop', () => {
|
||||
cancelAnimationFrame(frame); sample()
|
||||
host.removeEventListener('dsh-test-fold-growth', markGrowth)
|
||||
host.removeAttribute('data-fold-probe-ready'); resolveTrace(samples)
|
||||
}, { once: true })
|
||||
sample(); host.setAttribute('data-fold-probe-ready', ''); frame = requestAnimationFrame(tick)
|
||||
}), { marker: 'VISIBLE_FOLD_NEXT_0', race: scenario.name })
|
||||
await page.locator('[data-fold-probe-ready]').waitFor()
|
||||
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
|
||||
let samples: Awaited<typeof trace> = []
|
||||
try {
|
||||
settled = world.scaffold.whenTurnSettled(30_000)
|
||||
if (scenario.name === 'cancel-all-during-fold') {
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button')
|
||||
button.click()
|
||||
})
|
||||
} else await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await promptBlocked.promise
|
||||
await page.getByText(input, { exact: true }).waitFor()
|
||||
await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
|
||||
if (scenario.name !== 'cancel-all-during-fold') await expectBottom(page)
|
||||
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
|
||||
let previous = host.scrollTop
|
||||
let stableSince = performance.now()
|
||||
await new Promise<void>((resolveStable) => {
|
||||
const check = (): void => {
|
||||
const now = performance.now()
|
||||
if (Math.abs(host.scrollTop - previous) > 0.25) stableSince = now
|
||||
previous = host.scrollTop
|
||||
if (now - stableSince >= 240) resolveStable()
|
||||
else requestAnimationFrame(check)
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
})
|
||||
})
|
||||
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-growth')))
|
||||
promptGate.resolve(undefined)
|
||||
release()
|
||||
await finishBlocked.promise
|
||||
await page.getByText('VISIBLE_FOLD_DONE', { exact: false }).waitFor()
|
||||
await expectBottom(page)
|
||||
await nextPaint(page)
|
||||
} finally {
|
||||
promptGate.resolve(undefined)
|
||||
release()
|
||||
await page.unroute('**/api/session/prompt', holdPrompt)
|
||||
await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
|
||||
samples = await trace
|
||||
finishGate.resolve(undefined)
|
||||
holdFinish()
|
||||
await settled
|
||||
}
|
||||
const allSamples = samples
|
||||
const ordinarySamples = samples.filter(sample => sample.ordinaryGrowth)
|
||||
samples = samples.filter(sample => !sample.ordinaryGrowth)
|
||||
const first = samples[0]!
|
||||
const last = samples.at(-1)!
|
||||
const closedBoxesHeight = before.removed.reduce((sum, box) => sum + box.height, 0)
|
||||
const removedHeight = first.bodyY + first.top - last.bodyY - last.top
|
||||
const boundaryGrowth = closedBoxesHeight - removedHeight
|
||||
const aboveRemoved = Math.min(removedHeight,
|
||||
before.removed.reduce((sum, box) => sum + Math.min(box.height, Math.max(0, -box.top)), 0))
|
||||
const remainingHeight = last.height - last.spacer
|
||||
const newHeight = remainingHeight - before.height + removedHeight
|
||||
const baseTop = Math.max(0, before.top - aboveRemoved)
|
||||
const expectedTop = Math.max(baseTop, remainingHeight - before.viewport, 0)
|
||||
const expectedSpacer = expectedTop === 0 ? 0 : Math.max(0, expectedTop + before.viewport - remainingHeight)
|
||||
const ordinaryFollow = expectedTop - baseTop
|
||||
const expectedBodyShift = removedHeight - aboveRemoved + ordinaryFollow
|
||||
const bodyShift = first.bodyY - last.bodyY
|
||||
const reversalOf = (values: readonly number[]): number => {
|
||||
let lowest = values[0] ?? 0
|
||||
let reversal = 0
|
||||
for (const value of values) {
|
||||
lowest = Math.min(lowest, value)
|
||||
reversal = Math.max(reversal, value - lowest)
|
||||
}
|
||||
return reversal
|
||||
}
|
||||
const reversal = reversalOf(samples.map(sample => sample.bodyY))
|
||||
const inputReversal = reversalOf(samples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
||||
const ordinaryBodyReversal = reversalOf([samples.at(-1)!.bodyY, ...ordinarySamples.map(sample => sample.bodyY)])
|
||||
const ordinaryInputReversal = reversalOf(ordinarySamples.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
||||
const stepReversal = reversalOf(samples.flatMap(sample => sample.stepY === null ? [] : [sample.stepY]))
|
||||
const firstFoldIdle = samples.findIndex((sample, index) => index > 0 && !sample.folding && samples[index - 1]!.folding)
|
||||
const afterFold = firstFoldIdle < 0 ? [] : samples.slice(firstFoldIdle)
|
||||
const range = (values: number[]): number => values.length === 0 ? 0 : Math.max(...values) - Math.min(...values)
|
||||
const foldBoundaryMovement = firstFoldIdle > 0
|
||||
? Math.abs(samples[firstFoldIdle]!.bodyY - samples[firstFoldIdle - 1]!.bodyY) : 0
|
||||
const priorFrameMovements = samples.slice(1, Math.max(1, firstFoldIdle - 1))
|
||||
.map((sample, index) => Math.abs(sample.bodyY - samples[index]!.bodyY))
|
||||
const secondMovement = range(afterFold.map(sample => sample.bodyY))
|
||||
const secondInputMovement = range(afterFold.flatMap(sample => sample.messageY === null ? [] : [sample.messageY]))
|
||||
const earlierShift = first.earlierY - last.earlierY
|
||||
await writeFile(join(evidence, 'motion.json'), JSON.stringify({ scenario, before, closedBoxesHeight, removedHeight, boundaryGrowth, aboveRemoved,
|
||||
remainingHeight, newHeight, expectedTop, expectedSpacer, expectedBodyShift, bodyShift, ordinaryFollow,
|
||||
earlierShift, reversal, inputReversal, stepReversal, secondMovement, secondInputMovement,
|
||||
ordinaryBodyReversal, ordinaryInputReversal, samples: allSamples }, null, 2))
|
||||
console.log(`visible-fold ${scenario.name}: spacer=${last.spacer}/${expectedSpacer}, top=${last.top}/${expectedTop}, reversal=${reversal}, secondMovement=${secondMovement}, evidence=${evidence}`)
|
||||
if (scenario.name === 'cancel-all-during-fold') {
|
||||
expect(samples.some(sample => sample.raceApplied)).toBe(true)
|
||||
expect(last.folding).toBe(false)
|
||||
expect(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect(await page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
.getByText('Work details', { exact: true }).locator('../..')
|
||||
.getByRole('button', { name: 'Verbose', exact: true }).count()).toBe(1)
|
||||
expect(await page.locator('[data-input-fold]').count()).toBe(0)
|
||||
assertClean(world)
|
||||
return
|
||||
}
|
||||
if (scenario.name === 'content-growth-during-fold') {
|
||||
expect(samples.some(sample => sample.raceApplied && sample.folding)).toBe(true)
|
||||
expect.soft(foldBoundaryMovement).toBeLessThanOrEqual(Math.max(...priorFrameMovements) + GEOMETRY_TOLERANCE)
|
||||
}
|
||||
expect.soft(removedHeight).toBeGreaterThan(before.visibleHeight * 3)
|
||||
if (!scenario.reduced) {
|
||||
expect.soft(samples.some(sample => sample.folding)).toBe(true)
|
||||
const movingSamples = samples.filter((sample, index) => index > 0
|
||||
&& Math.abs(sample.bodyY - samples[index - 1]!.bodyY) > 0.25)
|
||||
expect.soft(movingSamples.length).toBeGreaterThan(1)
|
||||
expect.soft(firstFoldIdle).toBeGreaterThan(0)
|
||||
}
|
||||
if (scenario.name === 'visible-steps') {
|
||||
expect.soft(first.stepY).not.toBeNull()
|
||||
expect.soft(first.stepY!).toBeGreaterThanOrEqual(before.viewportTop)
|
||||
expect.soft(first.stepY!).toBeLessThan(first.composerTop)
|
||||
const initial = samples.filter(sample => sample.stepY !== null && sample.t <= samples[firstFoldIdle - 1]!.t)
|
||||
expect.soft(Math.max(...initial.map(sample => sample.stepY!)) - first.stepY!).toBeLessThanOrEqual(0.5)
|
||||
}
|
||||
expect.soft(Math.abs(last.top - expectedTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(Math.abs(last.spacer - expectedSpacer)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(Math.abs(bodyShift - expectedBodyShift)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(inputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(stepReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(ordinarySamples.length).toBeGreaterThan(0)
|
||||
expect.soft(ordinaryBodyReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(ordinaryInputReversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(secondMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(secondInputMovement).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
if (first.earlierY >= before.viewportTop) {
|
||||
expect.soft(Math.abs(earlierShift - ordinaryFollow)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
if (scenario.name === 'body-only' || scenario.name === 'large-input') expect.soft(last.spacer).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect.soft(last.messageY).not.toBeNull()
|
||||
expect.soft(last.messageY!).toBeLessThan(last.composerTop)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('resets transcript height when switching from Verbose back to Standard during a reply', async () => {
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'MODE', title: 'CHAT_MODE_HEIGHT', turns: 8 })
|
||||
@@ -1317,6 +1574,9 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
|
||||
const resolved = await world.scaffold.ctx.sessionController.resolveAgent(TOOL_SESSION_ID)
|
||||
if ('error' in resolved) throw resolved.error
|
||||
world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access')
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
@@ -1549,6 +1809,9 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
|
||||
const resolved = await world.scaffold.ctx.sessionController.resolveAgent(FLING_SESSION_ID)
|
||||
if ('error' in resolved) throw resolved.error
|
||||
world.scaffold.ctx.permissionPresets.set(resolved.agent.session, 'danger-full-access')
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
const releaseText = holdTextAfter(world, 16)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"colorScheme": "light",
|
||||
"wrapped": true,
|
||||
"text": "echo NAVIGATION_OK",
|
||||
"copiedText": "echo NAVIGATION_OK"
|
||||
},
|
||||
{
|
||||
"colorScheme": "dark",
|
||||
"wrapped": true,
|
||||
"text": "echo NAVIGATION_OK",
|
||||
"copiedText": "echo NAVIGATION_OK"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
- button "Think" [expanded]
|
||||
- paragraph: The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
|
||||
- Reasoning rows: 1
|
||||
@@ -0,0 +1,3 @@
|
||||
- button "Bash Start the diagnostic worker" [expanded]
|
||||
- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
- button "Inspect"
|
||||
@@ -1,5 +1,3 @@
|
||||
- button "Bash Start the diagnostic worker" [expanded]
|
||||
- text: IN
|
||||
- group "Command arguments": "{ \"command\": \"node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log\", \"description\": \"Start the diagnostic worker\", \"run_in_background\": true }"
|
||||
- text: OUT started background job bash-1
|
||||
- text: {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log
|
||||
- button "Inspect"
|
||||
@@ -0,0 +1,5 @@
|
||||
- button "Bash Start the diagnostic worker" [expanded]
|
||||
- text: Done {{workspace}} node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log --token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx $ echo done
|
||||
- button "Copy"
|
||||
- text: done
|
||||
- button "Inspect"
|
||||
@@ -1,18 +1,66 @@
|
||||
/** Cold-replayed Markdown references open Session files through the shipped Web composition. */
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { basename, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, 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/markdown-file-links', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Build live test input with a parent-path link and a read result naming the same Host file. */
|
||||
function parentPathFixture(path: string): string {
|
||||
const session = Session.create(SessionId('parent-file-links-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Read the parent-path file and link lines 24 and 30.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', { title: 'Parent file links', messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const callId = ToolCallId('parent-file-read')
|
||||
const args = JSON.stringify({ file_path: path, offset: 24, limit: 7 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1, step: 1, stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: args }],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: args })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, meta: { path: '{{cwd}}/parent.txt' },
|
||||
message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'parent line 24\n' }], isError: false }),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1, step: 2, stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: `[Parent file:24–30](<${path}#L24-L30>) · [Parent file:30](<${path}#L30>)` }],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 2 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
|
||||
cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: Markdown file links', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -78,4 +126,55 @@ describe('web e2e: Markdown file links', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'links.expected.md', 'preview.expected.md'])
|
||||
})
|
||||
|
||||
it('opens parent-path prose and read-card links while retaining each address across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-parent-file-links'))
|
||||
// Return through the parent into this owned temp root; no shared parent file is created.
|
||||
const path = `../${basename(scaffold.workspaceCwd)}/parent.txt`
|
||||
await writeFile(join(scaffold.workspaceCwd, 'parent.txt'),
|
||||
Array.from({ length: 60 }, (_, i) => `parent line ${i + 1}\n`).join(''))
|
||||
const id = await seedSession(scaffold, parentPathFixture(path), 'parent-file-links')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
const workspace = page.getByRole('treeitem').first()
|
||||
await workspace.waitFor()
|
||||
if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
|
||||
await page.locator(`[data-row-key="session:${id}"]`).click()
|
||||
|
||||
const source = page.getByRole('button', { name: 'Parent file:24–30', exact: true })
|
||||
await source.click()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
const stored = () => page.evaluate(key => localStorage.getItem(key), `dsh.sidebar-right.v1.${id}`)
|
||||
const address = sessionFileAddress(id, path)
|
||||
await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n')
|
||||
await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt'))
|
||||
await expect.poll(stored).toContain(address)
|
||||
const tabs = column.locator('[data-dockkit-tab-title]')
|
||||
expect(await tabs.allTextContents()).toEqual(['parent.txt'])
|
||||
|
||||
const later = page.getByRole('button', { name: 'Parent file:30', exact: true })
|
||||
await later.focus()
|
||||
await later.press('Enter')
|
||||
await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('parent line 30\n')
|
||||
expect(await tabs.allTextContents()).toEqual(['parent.txt'])
|
||||
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt'))
|
||||
await expect.poll(stored).toContain(address)
|
||||
await source.click()
|
||||
await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n')
|
||||
expect(await tabs.allTextContents()).toEqual(['parent.txt'])
|
||||
|
||||
const readCardLink = page.locator('[data-tool="read"] button').filter({ hasText: 'parent.txt' })
|
||||
await expandOwningTurnProcess(page, readCardLink)
|
||||
await readCardLink.click()
|
||||
await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(scaffold.workspaceCwd, 'parent.txt'))
|
||||
await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('parent line 24\n')
|
||||
// The Host-reported path and the unresolved prose path are distinct resource identities.
|
||||
expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt'])
|
||||
await expect.poll(stored).toContain(sessionFileAddress(id, 'parent.txt'))
|
||||
await source.click()
|
||||
expect(await tabs.allTextContents()).toEqual(['parent.txt', 'parent.txt'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { expectSelectableTerminalCommand } from './terminal-command-browser.ts'
|
||||
import { expectWrappedTerminalCommand } from './terminal-command-browser.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
@@ -29,6 +29,7 @@ const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
|
||||
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
|
||||
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
@@ -472,7 +473,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
|
||||
const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
await expectSelectableTerminalCommand(page, card, 'echo NAVIGATION_OK', 300)
|
||||
const commandWrap = await expectWrappedTerminalCommand(page, card, 'echo NAVIGATION_OK', 300)
|
||||
await compareOrRefreshGolden(COMMAND_WRAP_EXPECTED, JSON.stringify(commandWrap, null, 2), MODE)
|
||||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||||
// squeeze the output pane below its content width and the line must keep
|
||||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||||
@@ -547,7 +549,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md'])
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-experimental-webworker-runtime'
|
||||
import {
|
||||
VFS_EXAMPLE_SESSION_IDS,
|
||||
VFS_EXAMPLE_REASONING,
|
||||
VFS_EXAMPLE_WRAPPED_COMMAND,
|
||||
buildVfsExampleFiles,
|
||||
} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
|
||||
import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
|
||||
@@ -455,6 +457,57 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
await page.getByText('Update to-do list', { exact: true }).waitFor()
|
||||
await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
|
||||
|
||||
const reasoning = page.locator('[data-variant="think"]')
|
||||
await expect.poll(() => reasoning.count()).toBe(VFS_EXAMPLE_REASONING.length)
|
||||
for (const [index, text] of VFS_EXAMPLE_REASONING.entries()) {
|
||||
const row = reasoning.nth(index)
|
||||
await row.getByRole('button').click()
|
||||
await row.getByText(text.split('\n\n').at(-1)!, { exact: true }).waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
const wrappedCall = page.locator('[data-chat-call-id="preview-wrapped-bash"]')
|
||||
await wrappedCall.locator('[data-sample="bash"]').click()
|
||||
const terminal = wrappedCall.locator('[data-terminal]')
|
||||
const originalWidth = await terminal.evaluate(element => element.style.width)
|
||||
try {
|
||||
await terminal.evaluate((element) => { element.style.width = '380px' })
|
||||
const commands = terminal.locator('[data-command-text]')
|
||||
expect(await commands.allTextContents()).toEqual(VFS_EXAMPLE_WRAPPED_COMMAND.slice(0, -1).split('\n'))
|
||||
await commands.first().scrollIntoViewIfNeeded()
|
||||
const geometry = await commands.first().evaluate((element) => {
|
||||
const prompt = element.parentElement!
|
||||
const header = element.closest('[class*="_header_"]')!
|
||||
header.scrollTop = 0
|
||||
const dot = prompt.querySelector('[data-state="done"]')
|
||||
if (dot === null) throw new Error('wrapped preview command has no successful status dot')
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(element)
|
||||
const lines = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0)
|
||||
const marker = dot.getBoundingClientRect()
|
||||
const viewport = header.getBoundingClientRect()
|
||||
return {
|
||||
wrapped: new Set(lines.map(rect => Math.round(rect.top))).size > 1,
|
||||
whitespace: getComputedStyle(element).whiteSpace,
|
||||
alignmentError: Math.abs(marker.top + marker.height / 2
|
||||
- (prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2)),
|
||||
visible: marker.top >= viewport.top && marker.bottom <= viewport.bottom,
|
||||
}
|
||||
})
|
||||
expect(geometry.wrapped).toBe(true)
|
||||
expect(geometry.whitespace).toBe('pre-wrap')
|
||||
expect(geometry.alignmentError).toBeLessThanOrEqual(1)
|
||||
expect(geometry.visible).toBe(true)
|
||||
expect(await terminal.locator('[data-state="done"]').count()).toBe(1)
|
||||
} finally {
|
||||
await terminal.evaluate((element, width) => { element.style.width = width }, originalWidth)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Parent workspace guide:1–8', exact: true }).click()
|
||||
const preview = page.locator('[data-rightbar-col]')
|
||||
await expect.poll(() => preview.locator('[data-textpreview-path]').textContent()).toBe('/dsh/workspace/PREVIEW.md')
|
||||
await preview.getByRole('heading', { name: 'Preview Workspace', exact: true }).waitFor({ state: 'visible' })
|
||||
expect(await preview.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['PREVIEW.md'])
|
||||
|
||||
const subagents = page.getByRole('button', { name: '2 subagents', exact: true })
|
||||
await subagents.waitFor({ timeout: 15_000 })
|
||||
await subagents.hover()
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ContextFormed, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -41,6 +41,7 @@ type CheckpointSource = Extract<MessageSource, { readonly kind: 'compact-checkpo
|
||||
type CheckpointCommandId = NonNullable<CheckpointSource['sourceCommandId']>
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
|
||||
const REASONING_EXPECTED_DIR = fileURLToPath(new URL('./expected/seeded-history', import.meta.url))
|
||||
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.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(
|
||||
@@ -856,11 +857,68 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('omits blank reasoning rows around recorded text in an appended fixture turn', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-blank-reasoning'))
|
||||
const session = scaffold.ctx.sessions.get(SessionId(SEED_ID))
|
||||
if (session === undefined) throw new Error('seeded session is unavailable')
|
||||
const events = session.snapshotEvents()
|
||||
const recorded = events.flatMap(event => event.type === 'assistant/message' ? event.data.message.content : [])
|
||||
.find(block => block.type === 'reasoning' && block.text.trim() !== '')
|
||||
if (recorded?.type !== 'reasoning') throw new Error('seeded history requires recorded reasoning text')
|
||||
const previousEnd = events.findLast(event => event.type === 'turn/end')
|
||||
if (previousEnd?.type !== 'turn/end') throw new Error('seeded history requires a closed turn')
|
||||
const turn = previousEnd.data.turn + 1
|
||||
const answer = 'BLANK_REASONING_FIXTURE_DONE'
|
||||
// The blank blocks are a test condition around authentic recorded text, not model output.
|
||||
session.append('turn/start', { turn })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show recorded reasoning surrounded by blank blocks.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const message = session.append('assistant/message', {
|
||||
turn, step: 1, stream: [],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
content: [
|
||||
{ type: 'reasoning', text: '' },
|
||||
{ type: 'reasoning', text: recorded.text },
|
||||
{ type: 'reasoning', text: ' \t\r\n' },
|
||||
{ type: 'reasoning', text: '' },
|
||||
{ type: 'text', text: answer },
|
||||
],
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
await scaffold.ctx.sessions.flush(session)
|
||||
await page.getByText(answer, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const selector = `[data-chat-turn="${turn}"][data-chat-group-part="reasoning"]`
|
||||
const rows = page.locator(selector).locator('[data-variant="think"]')
|
||||
await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expandOwningTurnProcess(page, rows.first())
|
||||
const toggle = rows.getByRole('button').first()
|
||||
expect(await toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
const body = rows.locator('[data-markdown-variant="compact"]')
|
||||
await body.waitFor({ state: 'visible' })
|
||||
expect(await body.innerText()).not.toBe('')
|
||||
expect(message.data.message.content[1]).toEqual({ type: 'reasoning', text: recorded.text })
|
||||
await compareOrRefreshGolden(join(REASONING_EXPECTED_DIR, 'blank-reasoning.expected.md'), [
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
'',
|
||||
`- Reasoning rows: ${await rows.count()}`,
|
||||
].join('\n'), MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
// No replay fixture was installed and the llm seam is open — any stray
|
||||
// stream would have failed the turn loudly. Cleanliness pins the wire.
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(REASONING_EXPECTED_DIR, ['blank-reasoning.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
|
||||
'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md',
|
||||
|
||||
@@ -8,21 +8,22 @@ import {
|
||||
seedSession, watchConsole, webSnapshotMode,
|
||||
} from './scaffold.ts'
|
||||
import { expandOwningTurnProcess, newEnglishPage } from './support.ts'
|
||||
import { expectCommandFocusAfterResize, expectSelectableCommandText } from './terminal-command-browser.ts'
|
||||
import { expectFirstLineTerminalState, expectWrappedCommandText } from './terminal-command-browser.ts'
|
||||
|
||||
const COMMAND = 'node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log'
|
||||
const ARGS = { command: COMMAND, description: 'Start the diagnostic worker', run_in_background: true }
|
||||
const OUTPUT = 'started background job bash-1'
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/shell-command-details/background.expected.md', import.meta.url))
|
||||
const LONG_ARGS = { ...ARGS, command: `${COMMAND} --token=${'x'.repeat(1024)}` }
|
||||
|
||||
/** A settled background launch acknowledgement rendered from durable call arguments. */
|
||||
function backgroundFixture(): string {
|
||||
/** A settled shell call rendered from durable arguments and its original result. */
|
||||
function shellFixture(arguments_: typeof ARGS, output: string): string {
|
||||
const session = Session.create(SessionId('shell-command-details-source'))
|
||||
const callId = ToolCallId('shell-command-details-background')
|
||||
const args = JSON.stringify(ARGS)
|
||||
const args = JSON.stringify(arguments_)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Start the diagnostic worker in the background.' }],
|
||||
content: [{ type: 'text', text: arguments_.run_in_background
|
||||
? 'Start the diagnostic worker in the background.' : 'Run the diagnostic worker.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -36,7 +37,7 @@ function backgroundFixture(): string {
|
||||
const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: args })
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({ callId, content: [{ type: 'text', text: OUTPUT }], isError: false }),
|
||||
message: createToolResultMessage({ callId, content: [{ type: 'text', text: output }], isError: false }),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
@@ -56,10 +57,18 @@ function backgroundFixture(): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
it('expands a background Bash launch to selectable arguments and its original acknowledgement', async () => {
|
||||
it.each([
|
||||
{ name: 'background', args: ARGS, output: OUTPUT },
|
||||
{ name: 'background-long-token', args: LONG_ARGS, output: OUTPUT },
|
||||
{
|
||||
name: 'foreground-multiline',
|
||||
args: { ...LONG_ARGS, command: `${LONG_ARGS.command}\necho done\n`, run_in_background: false },
|
||||
output: 'done\n[exit code: 0]',
|
||||
},
|
||||
])('expands $name to wrapped commands with only foreground output', async ({ name, args, output }) => {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
try {
|
||||
await seedSession(scaffold, backgroundFixture(), 'shell-command-details-browser')
|
||||
await seedSession(scaffold, shellFixture(args, output), 'shell-command-details-browser')
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await newEnglishPage(browser)
|
||||
@@ -76,25 +85,52 @@ it('expands a background Bash launch to selectable arguments and its original ac
|
||||
expect(await row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
const card = row.locator('..')
|
||||
const input = card.locator('[data-command-text]')
|
||||
const commands = card.locator('[data-command-text]')
|
||||
const input = commands.first()
|
||||
await input.waitFor()
|
||||
expect(await input.textContent()).toBe(JSON.stringify(ARGS, null, 2))
|
||||
expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(1)
|
||||
expect(await card.locator('[data-terminal]').count()).toBe(0)
|
||||
const background = args.run_in_background
|
||||
const selectableText = args.command.split('\n')[0]!
|
||||
const inputSection = card.locator('[data-terminal] [class*="_header_"]')
|
||||
expect(await input.textContent()).toBe(selectableText)
|
||||
if (background) {
|
||||
expect(await commands.allTextContents()).toEqual([args.command])
|
||||
expect(await card.locator('[data-terminal]').count()).toBe(1)
|
||||
expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0)
|
||||
expect(await card.locator('[data-terminal][data-body], [data-terminal][data-running], [data-terminal] [data-state]').count()).toBe(0)
|
||||
expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0)
|
||||
} else {
|
||||
expect(await commands.allTextContents()).toEqual([selectableText, 'echo done'])
|
||||
expect(await card.locator('[data-terminal]').count()).toBe(1)
|
||||
expect(await card.getByText('done', { exact: true }).count()).toBe(1)
|
||||
}
|
||||
expect(await card.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1)
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark')
|
||||
for (const width of ['', '380px']) {
|
||||
for (const width of ['', '380px', '']) {
|
||||
await card.evaluate((element, value) => { element.style.width = value }, width)
|
||||
await input.scrollIntoViewIfNeeded()
|
||||
await expectSelectableCommandText(page, input, COMMAND, 'Command arguments')
|
||||
await expectCommandFocusAfterResize(card, input)
|
||||
if (!background && width === '380px') await expectFirstLineTerminalState(card.locator('[data-terminal]'), 'done')
|
||||
await expectWrappedCommandText(page, input, selectableText, inputSection)
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
if (name !== 'background') {
|
||||
expect(await inputSection.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await inputSection.evaluate((element) => { element.scrollTop = 0 })
|
||||
await row.focus()
|
||||
await page.keyboard.press('Tab')
|
||||
const focusTarget = background ? inputSection : card.getByRole('button', { name: 'Copy', exact: true })
|
||||
expect(await focusTarget.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press('End')
|
||||
await expect.poll(() => inputSection.evaluate(element =>
|
||||
element.scrollTop + element.clientHeight >= element.scrollHeight - 1)).toBe(true)
|
||||
await page.keyboard.press('Home')
|
||||
await expect.poll(() => inputSection.evaluate(element => element.scrollTop)).toBe(0)
|
||||
}
|
||||
}
|
||||
await card.evaluate((element) => { element.style.width = '' })
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
await compareOrRefreshGolden(EXPECTED,
|
||||
const expected = fileURLToPath(new URL(`./expected/shell-command-details/${name}.expected.md`, import.meta.url))
|
||||
await compareOrRefreshGolden(expected,
|
||||
await captureStableAria(page, '[data-chat-call-id="shell-command-details-background"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
@@ -105,3 +141,94 @@ it('expands a background Bash launch to selectable arguments and its original ac
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'bash', background: false },
|
||||
{ name: 'pwsh', background: false },
|
||||
{ name: 'bash', background: true },
|
||||
{ name: 'pwsh', background: true },
|
||||
] as const)('keeps the $name command mounted through settlement (background=$background)', async ({ name, background }) => {
|
||||
const scaffold = await launchWebScaffold({})
|
||||
try {
|
||||
const id = await seedSession(scaffold, shellFixture(ARGS, OUTPUT), 'shell-status-dot-browser')
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.getByRole('treeitem').first().click()
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.locator('[data-sample="bash"]').waitFor({ state: 'attached' })
|
||||
const resolved = await scaffold.ctx.sessionController.resolveAgent(id)
|
||||
if ('error' in resolved) throw resolved.error
|
||||
const session = resolved.agent.session
|
||||
const callId = ToolCallId(`status-dot-${name}-${background ? 'background' : 'foreground'}`)
|
||||
const args = JSON.stringify({ command: LONG_ARGS.command, description: `Long ${name} command`, run_in_background: background })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show a long running command.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 2, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 2, step: 1, stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'tool-call', id: callId, name, arguments: args }],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
const call = session.append('tool/call', { turn: 2, step: 1, callId, name, arguments: args })
|
||||
await scaffold.ctx.sessions.flush(session)
|
||||
const callRow = page.locator(`[data-chat-call-id="${callId}"]`)
|
||||
const toggle = callRow.locator('[data-expandable]').first()
|
||||
await toggle.waitFor({ state: 'attached' })
|
||||
await expandOwningTurnProcess(page, toggle)
|
||||
await toggle.click()
|
||||
const card = callRow.locator('[data-terminal]')
|
||||
await card.waitFor()
|
||||
await card.evaluate((element) => { element.style.width = '380px' })
|
||||
await card.scrollIntoViewIfNeeded()
|
||||
const header = card.locator('[class*="_header_"]')
|
||||
const mountedCard = await card.elementHandle()
|
||||
if (mountedCard === null) throw new Error('Running command card is not mounted')
|
||||
expect(await header.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(0)
|
||||
for (const state of ['ongoing', 'done'] as const) {
|
||||
if (state === 'done') {
|
||||
session.append('tool/result', {
|
||||
turn: 2, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId, content: [{ type: 'text', text: background ? OUTPUT : 'status dot output\n[exit code: 0]' }], isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
|
||||
session.append('step/end', { turn: 2, step: 1 })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await scaffold.ctx.sessions.flush(session)
|
||||
}
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').evaluate(element =>
|
||||
element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark')
|
||||
if (background && state === 'done') {
|
||||
await expect.poll(() => card.getAttribute('data-running')).toBeNull()
|
||||
expect(await card.evaluate((element, previous) => element === previous, mountedCard)).toBe(true)
|
||||
expect(await toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await card.getAttribute('data-body')).toBeNull()
|
||||
expect(await card.getAttribute('data-running')).toBeNull()
|
||||
expect(await card.locator('[data-state]').count()).toBe(0)
|
||||
} else await expectFirstLineTerminalState(card, state)
|
||||
expect(await card.locator('[data-command-text]').textContent()).toBe(LONG_ARGS.command)
|
||||
}
|
||||
}
|
||||
expect(await card.getByText('status dot output', { exact: true }).count()).toBe(background ? 0 : 1)
|
||||
expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(0)
|
||||
expect(await card.getByRole('button', { name: 'Copy', exact: true }).count()).toBe(background ? 0 : 1)
|
||||
expect(await callRow.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,138 @@ export async function expectSelectableTerminalCommand(page: Page, card: Locator,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select and copy text that wraps through the consumer's own stylesheet.
|
||||
* @param page - Browser page that owns the text.
|
||||
* @param command - One authored command line or the complete formatted arguments.
|
||||
* @param expected - Exact selectable text, including authored whitespace.
|
||||
* @param scrollport - Existing vertical scroll container around the text.
|
||||
* @returns Observed wrapping and native clipboard text for a semantic snapshot.
|
||||
*/
|
||||
export async function expectWrappedCommandText(
|
||||
page: Page, command: Locator, expected: string, scrollport: Locator,
|
||||
): Promise<{ wrapped: boolean; text: string | null; copiedText: string }> {
|
||||
expect(await command.textContent()).toBe(expected)
|
||||
expect(await command.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
|
||||
await expectNoCommandTabStop(command)
|
||||
const wrapped = await command.evaluate((element, value) => {
|
||||
const text = element.firstChild
|
||||
if (!(text instanceof Text)) throw new Error('Command has no text node')
|
||||
const longest = value.split('\n').reduce((a, b) => a.length >= b.length ? a : b)
|
||||
const offset = value.indexOf(longest)
|
||||
const range = document.createRange()
|
||||
range.setStart(text, offset)
|
||||
range.setEnd(text, offset + longest.length)
|
||||
const rows = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0)
|
||||
.map(rect => Math.round(rect.top))
|
||||
return new Set(rows).size > 1
|
||||
}, expected)
|
||||
expect(wrapped).toBe(true)
|
||||
expect(await scrollport.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const originalScrollTop = await scrollport.evaluate(element => element.scrollTop)
|
||||
const geometry = () => command.evaluate((element) => {
|
||||
const text = element.firstChild
|
||||
if (!(text instanceof Text) || text.length === 0) throw new Error('Command has no text node')
|
||||
const first = document.createRange()
|
||||
first.setStart(text, 0)
|
||||
first.setEnd(text, 1)
|
||||
const last = document.createRange()
|
||||
last.setStart(text, text.length - 1)
|
||||
last.setEnd(text, text.length)
|
||||
const head = first.getBoundingClientRect()
|
||||
const tail = last.getBoundingClientRect()
|
||||
return { head: { x: head.left, y: head.top + head.height / 2 }, tail: { x: tail.right, y: tail.top + tail.height / 2 } }
|
||||
})
|
||||
try {
|
||||
await scrollport.scrollIntoViewIfNeeded()
|
||||
await scrollport.evaluate((element) => { element.scrollTop = 0 })
|
||||
const start = await geometry()
|
||||
const viewport = await scrollport.boundingBox()
|
||||
if (viewport === null) throw new Error('Command scrollport is not visible')
|
||||
await page.mouse.move(start.head.x + 0.1, start.head.y)
|
||||
await page.mouse.down()
|
||||
try {
|
||||
if (start.tail.y > viewport.y + viewport.height) {
|
||||
await page.mouse.move(start.head.x + 0.1, viewport.y + viewport.height - 2)
|
||||
await page.mouse.wheel(0, await scrollport.evaluate(element => element.scrollHeight))
|
||||
await expect.poll(async () => (await geometry()).tail.y < viewport.y + viewport.height).toBe(true)
|
||||
}
|
||||
const end = await geometry()
|
||||
await page.mouse.move(end.tail.x - 0.1, end.tail.y, { steps: 5 })
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).toBe(expected)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
const sentinel = `${expected}\nclipboard-before-copy`
|
||||
await page.evaluate(value => navigator.clipboard.writeText(value), sentinel)
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel)
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expected)
|
||||
return { wrapped, text: await command.textContent(), copiedText: await page.evaluate(() => navigator.clipboard.readText()) }
|
||||
} finally {
|
||||
await scrollport.evaluate((element, top) => { element.scrollTop = top }, originalScrollTop)
|
||||
await command.evaluate(() => { document.getSelection()?.removeAllRanges() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a chat terminal wraps at a narrow width without changing its text.
|
||||
* @param page - Browser page that owns the card.
|
||||
* @param card - Chat terminal card with consumer-owned wrapping styles.
|
||||
* @param expected - Complete single authored command line.
|
||||
* @param width - Narrow card width that forces soft wrapping.
|
||||
* @returns Observed command wrapping and copied text in both themes.
|
||||
*/
|
||||
export async function expectWrappedTerminalCommand(page: Page, card: Locator, expected: string, width: number) {
|
||||
const originalWidth = await card.evaluate(element => element.style.width)
|
||||
const command = card.locator('[data-command-text]').first()
|
||||
const header = card.locator('[class*="_header_"]').first()
|
||||
const observations: ({ colorScheme: 'light' | 'dark' } & Awaited<ReturnType<typeof expectWrappedCommandText>>)[] = []
|
||||
try {
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark')
|
||||
await card.evaluate((element, value) => { element.style.width = `${value}px` }, width)
|
||||
observations.push({ colorScheme, ...await expectWrappedCommandText(page, command, expected, header) })
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
await card.evaluate((element, value) => { element.style.width = value }, originalWidth)
|
||||
await expectNoCommandTabStop(command)
|
||||
expect(await command.textContent()).toBe(expected)
|
||||
}
|
||||
return observations
|
||||
} finally {
|
||||
await card.evaluate((element, value) => { element.style.width = value }, originalWidth)
|
||||
await page.emulateMedia({ colorScheme: null })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a terminal's status dot remains visible beside the first visual command line.
|
||||
* @param card - Terminal card with its command banner scrolled to the start.
|
||||
* @param state - Expected running or settled state.
|
||||
* @returns Resolves after the dot fits inside the banner and aligns with the first line.
|
||||
*/
|
||||
export async function expectFirstLineTerminalState(card: Locator, state: 'ongoing' | 'done'): Promise<void> {
|
||||
const dot = card.locator('[class*="_runState_"][data-state]')
|
||||
await expect.poll(() => dot.getAttribute('data-state')).toBe(state)
|
||||
const geometry = await dot.evaluate((element) => {
|
||||
const prompt = element.closest('[class*="_promptLine_"]')
|
||||
const header = element.closest('[class*="_header_"]')
|
||||
if (prompt === null || header === null) throw new Error('Terminal status has no prompt banner')
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const viewport = header.getBoundingClientRect()
|
||||
return {
|
||||
visible: bounds.top >= viewport.top && bounds.bottom <= viewport.bottom
|
||||
&& bounds.left >= viewport.left && bounds.right <= viewport.right,
|
||||
center: bounds.top + bounds.height / 2,
|
||||
firstLineCenter: prompt.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(prompt).lineHeight) / 2,
|
||||
}
|
||||
})
|
||||
expect(geometry.visible).toBe(true)
|
||||
expect(geometry.center).toBeCloseTo(geometry.firstLineCenter, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a resized command drops its explicit keyboard stop when all text fits.
|
||||
* @param container - Width owner of the command.
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 465f0c330fc1054b
|
||||
zh: bd0b68a05790fff8
|
||||
/right-sidebar/tab-type-registration:
|
||||
en: 1e01c817c5879c40
|
||||
zh: a79abd92ee4c8f85
|
||||
en: b7b8a7b411aee94d
|
||||
zh: 7298c20000945d97
|
||||
/right-sidebar/navigation-ctx-sidebarright:
|
||||
en: 0f80b5364cbad804
|
||||
zh: db81652a5374fad1
|
||||
|
||||
@@ -46,6 +46,8 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres
|
||||
| `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. |
|
||||
| `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
|
||||
|
||||
Whole-address patterns match the raw address: picomatch globstar (`**`) excludes standalone `.` and `..` segments even with `dot: true`, which includes dotfiles but does not change that exclusion. Resource-specific code parses addresses; the Host enforces file-read permissions.
|
||||
|
||||
Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error.
|
||||
|
||||
One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws.
|
||||
|
||||
@@ -46,6 +46,8 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用
|
||||
| `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 |
|
||||
| `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
|
||||
|
||||
完整地址模式匹配地址原文:picomatch 的 globstar(`**`)即使启用 `dot: true` 也排除独立的 `.` 和 `..` 段;该选项允许匹配 dotfile,不改变点段排除规则。地址由资源业务代码解析,文件读取仍受 Host 文件系统权限限制。
|
||||
|
||||
路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。
|
||||
|
||||
同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 4cd7c81d70d2f365
|
||||
zh: 18eca6d16e44b6c2
|
||||
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
|
||||
en: 8b2586e6b9f188a7
|
||||
zh: 6687f9e67432d04f
|
||||
en: 106438891c5175ce
|
||||
zh: 34dd30092f34f49b
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 5bcd9284adc0e0a7
|
||||
zh: cf633bb13e2c63b2
|
||||
|
||||
@@ -111,7 +111,7 @@ Scroll-edge fades initialize when `ResizeObserver` reports the open group's layo
|
||||
|
||||
Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted.
|
||||
|
||||
The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy.
|
||||
The session-scoped `conversation.chat.reasoning.body` single Slot offers the original reasoning text and streaming state only when its body is expanded. Its official entry registers at priority 100 and calls the `conversation.chat.reasoning.content` Factory. A replacement uses a lower priority number and may call that same Factory with original or derived text. The root-scoped Factory requires no Session and fixes compact typography. Its optional `labels` accepts complete `MarkdownLabels` unchanged; omission uses Chat's localized defaults. The host supplies no inline fallback or plugin action state. Display extensions must preserve access to the original text and own their controls inside their Body wrapper. Extensions do not replace the reasoning preview or its disclosure policy. The [reasoning display rules](src/client/conversation-nodes/README.md#display-modes) define which blocks produce rows.
|
||||
|
||||
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时
|
||||
|
||||
每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。
|
||||
|
||||
Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。
|
||||
Session 作用域的 `conversation.chat.reasoning.body` single Slot 仅在思考正文展开时提供原始思考文本和流式状态。官方条目以优先级 100 注册,并调用 `conversation.chat.reasoning.content` Factory。替换项使用更小的优先级数值,可向同一个 Factory 传入原文或派生显示文本。root 作用域的 Factory 不要求 Session,并固定使用紧凑排版。其可选 `labels` 原样接受完整的 `MarkdownLabels`,省略时使用 Chat 的默认本地化文案。宿主不提供内联 fallback 或插件操作状态。显示扩展必须保留查看原文的能力,并在自己的 Body 包装内维护控件。扩展不替换思考预览或展开策略。哪些块生成思考行由[思考展示规则](src/client/conversation-nodes/README.zh.md#display-modes)定义。
|
||||
|
||||
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface AssistantMarkdownProps extends Pick<PropsRenderSlots<'conversat
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
/** Renders non-blank reasoning blocks as independent disclosures alongside the response. */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, renderMessageImages, groupPart, useDisclosure,
|
||||
reasoningHidden = false, usePresentation, revealProcess, mentions, renderSlot, useGroupAction, t,
|
||||
@@ -92,6 +92,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
)
|
||||
break
|
||||
case 'reasoning':
|
||||
if (block.text.trim() === '') break
|
||||
rendered.push(
|
||||
<ProcessReasoning
|
||||
key={i}
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.column[data-input-fold] > :not([data-chat-motion]),
|
||||
.column[data-input-fold] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.column[data-chat-motion] > :not([data-chat-motion]),
|
||||
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Fade-then-close transitions owned by one Chat viewport. Rows fade before their boxes close;
|
||||
* the owner reserves removed height and holds scrolling until that viewport's folds finish.
|
||||
* Motion is opt-in under `[data-chat-motion]`, respects reduced motion, and clears temporary styles.
|
||||
* Fold transitions owned by one Chat viewport. Input folds coordinate box sizes and scrolling on
|
||||
* one frame clock; other folds use CSS transitions. Motion is opt-in under `[data-chat-motion]`,
|
||||
* respects reduced motion, and clears temporary styles.
|
||||
*/
|
||||
import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
import { useSearchableHidden, type HiddenTransition } from './searchable-hidden.ts'
|
||||
@@ -24,12 +24,53 @@ export interface FlowMotionRows {
|
||||
cancelMotion(element: HTMLElement): void
|
||||
}
|
||||
|
||||
/** Geometry captured before one input-owned fold changes its boxes. */
|
||||
export interface InputFoldBox {
|
||||
readonly element: HTMLElement
|
||||
readonly height: number
|
||||
readonly marginTop: number
|
||||
readonly closing: boolean
|
||||
}
|
||||
|
||||
/** The viewport follows the same progress that changes the folded boxes. */
|
||||
export interface InputFoldDriver {
|
||||
/**
|
||||
* Capture geometry before the frame clock changes the boxes.
|
||||
* @param boxes - non-nested boxes whose layout changes on this clock.
|
||||
*/
|
||||
begin(boxes: readonly InputFoldBox[]): void
|
||||
/**
|
||||
* Reserve scroll range before box writes; omit when the driver needs no pre-write compensation.
|
||||
* @param progress - shared eased progress from zero to one.
|
||||
* @param cancelled - automatic following has stopped; reader-position compensation still applies.
|
||||
*/
|
||||
prepare?(progress: number, cancelled: boolean): void
|
||||
/**
|
||||
* Apply viewport geometry after box writes, even when automatic following is cancelled.
|
||||
* @param progress - shared eased progress from zero to one.
|
||||
* @param cancelled - preserve the reader's position rather than applying the automatic target.
|
||||
* @returns actual scrollTop displacement from the batch start, for closing-content visual compensation.
|
||||
*/
|
||||
frame(progress: number, cancelled: boolean): number
|
||||
/**
|
||||
* Release temporary geometry after the last frame or cancellation.
|
||||
* @param cancelled - clean up without reacquiring follow ownership or committing an obsolete landing.
|
||||
*/
|
||||
finish(cancelled: boolean): void
|
||||
}
|
||||
|
||||
/** Animation lifetime and fold coordination for one viewport. */
|
||||
export interface FlowMotion extends FlowMotionRows {
|
||||
/** @returns whether a row owned by this viewport is closing. */
|
||||
foldActive(): boolean
|
||||
/** @param listener - callback after the last fold. @returns cancellation for a pending callback. */
|
||||
onFoldIdle(listener: () => void): () => void
|
||||
/** @param driver - one input-owned viewport motion. @returns whether an active fold was adopted. */
|
||||
followInput(driver: InputFoldDriver): boolean
|
||||
/** Stop input-owned scrolling while letting its closing rows finish. */
|
||||
cancelInput(): void
|
||||
/** @returns whether a coordinated fold currently owns layout and scrolling. */
|
||||
inputActive(): boolean
|
||||
/** Cancel owned animations and pending work when the viewport detaches. */
|
||||
clear(): void
|
||||
}
|
||||
@@ -66,6 +107,101 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
const pendingReserve = new Set<HTMLElement>()
|
||||
let pendingGrowth = 0
|
||||
let reserveScheduled = false
|
||||
const boxes = new Map<HTMLElement, InputFoldBox & { take: () => void; finish: () => void }>()
|
||||
let inputDriver: InputFoldDriver | null = null
|
||||
let inputFrame: number | null = null
|
||||
let inputBatch = false
|
||||
let clearEpoch = 0
|
||||
let inputCleanup: (() => void) | null = null
|
||||
let activeDriver: InputFoldDriver | null = null
|
||||
let cancelEmptyBatch: (() => void) | null = null
|
||||
|
||||
function cancelInput(): void { inputDriver = null }
|
||||
|
||||
function followInput(driver: InputFoldDriver): boolean {
|
||||
if (folding === 0) return false
|
||||
if (inputBatch) {
|
||||
inputDriver = activeDriver
|
||||
return true
|
||||
}
|
||||
inputDriver = driver
|
||||
scheduleReserve()
|
||||
return true
|
||||
}
|
||||
|
||||
function runInputBatch(): void {
|
||||
const driver = inputDriver
|
||||
if (driver === null || inputBatch) return
|
||||
const owned = [...boxes.values()]
|
||||
const batch = owned.filter(box => box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null)
|
||||
.sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
|
||||
if (!batch.some(box => box.closing)) return
|
||||
inputBatch = true
|
||||
activeDriver = driver
|
||||
driver.begin(batch)
|
||||
inputCleanup = () => { driver.finish(true) }
|
||||
for (const box of owned) box.take()
|
||||
for (const box of batch) {
|
||||
box.element.style.transition = 'none'
|
||||
box.element.style.height = `${box.closing ? box.height : 0}px`
|
||||
box.element.style.marginTop = `${box.closing ? box.marginTop : 0}px`
|
||||
box.element.style.opacity = box.closing ? '1' : '0'
|
||||
}
|
||||
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
let started: number | undefined
|
||||
let displacement = 0
|
||||
cancelEmptyBatch = () => {
|
||||
if (batch.some(box => box.closing && boxes.get(box.element) === box)) return
|
||||
if (inputFrame !== null) cancelAnimationFrame(inputFrame)
|
||||
inputFrame = null
|
||||
inputBatch = false
|
||||
activeDriver = null
|
||||
inputDriver = null
|
||||
inputCleanup = null
|
||||
cancelEmptyBatch = null
|
||||
for (const box of owned) if (boxes.get(box.element) === box) box.finish()
|
||||
driver.finish(true)
|
||||
}
|
||||
const tick = (now: number): void => {
|
||||
inputFrame = null
|
||||
started ??= now
|
||||
const elapsed = reduced ? CLOSE_DELAY_MS + CLOSE_MS : now - started
|
||||
const linear = Math.min(1, Math.max(0, (elapsed - CLOSE_DELAY_MS) / CLOSE_MS))
|
||||
const progress = linear * linear * (3 - 2 * linear)
|
||||
driver.prepare?.(progress, inputDriver !== driver)
|
||||
for (const box of batch) {
|
||||
if (boxes.get(box.element) !== box) continue
|
||||
const remaining = box.closing ? 1 - progress : progress
|
||||
box.element.style.height = `${box.height * remaining}px`
|
||||
box.element.style.marginTop = `${box.marginTop * remaining}px`
|
||||
box.element.style.opacity = box.closing ? String(Math.max(0, 1 - elapsed / FADE_OUT_MS)) : String(progress)
|
||||
}
|
||||
displacement = driver.frame(progress, inputDriver !== driver)
|
||||
let removedBefore = 0
|
||||
for (const box of batch) {
|
||||
if (boxes.get(box.element) !== box) continue
|
||||
if (box.closing) {
|
||||
// Transforming the closing box changes its pixels, not its flow contribution.
|
||||
box.element.style.translate = `0 ${displacement + removedBefore}px`
|
||||
removedBefore += (box.height + box.marginTop) * progress
|
||||
} else removedBefore -= (box.height + box.marginTop) * progress
|
||||
}
|
||||
if (linear < 1) {
|
||||
inputFrame = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
for (const box of owned) box.finish()
|
||||
inputBatch = false
|
||||
activeDriver = null
|
||||
inputCleanup = null
|
||||
cancelEmptyBatch = null
|
||||
const cancelled = inputDriver !== driver
|
||||
if (!cancelled) inputDriver = null
|
||||
driver.finish(cancelled)
|
||||
}
|
||||
if (reduced) tick(0)
|
||||
else inputFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function foldActive(): boolean { return folding > 0 }
|
||||
|
||||
@@ -77,6 +213,18 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
|
||||
function flushReserve(): void {
|
||||
reserveScheduled = false
|
||||
if (inputDriver !== null && !inputBatch) {
|
||||
pendingReserve.clear()
|
||||
pendingGrowth = 0
|
||||
runInputBatch()
|
||||
return
|
||||
}
|
||||
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
pendingReserve.clear()
|
||||
pendingGrowth = 0
|
||||
for (const box of [...boxes.values()]) box.finish()
|
||||
return
|
||||
}
|
||||
let total = -pendingGrowth
|
||||
pendingGrowth = 0
|
||||
for (const element of pendingReserve) {
|
||||
@@ -92,7 +240,8 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
if (reserveScheduled) return
|
||||
reserveScheduled = true
|
||||
// After the whole commit's collapses and reveals have started, before any box changes size.
|
||||
queueMicrotask(flushReserve)
|
||||
const epoch = clearEpoch
|
||||
queueMicrotask(() => { if (epoch === clearEpoch) flushReserve() })
|
||||
}
|
||||
|
||||
function foldStarted(): void {
|
||||
@@ -113,18 +262,21 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
element.removeEventListener('transitionend', onEnd)
|
||||
clearTimeout(timer)
|
||||
running.delete(element)
|
||||
boxes.delete(element)
|
||||
const { style } = element
|
||||
style.height = ''
|
||||
style.marginTop = ''
|
||||
style.opacity = ''
|
||||
style.overflow = ''
|
||||
style.transition = ''
|
||||
style.translate = ''
|
||||
delete element.dataset.chatMotion
|
||||
}
|
||||
|
||||
function cancelMotion(element: HTMLElement): void {
|
||||
pendingReserve.delete(element)
|
||||
running.get(element)?.()
|
||||
cancelEmptyBatch?.()
|
||||
}
|
||||
|
||||
function collapse(element: HTMLElement, done: () => void): void {
|
||||
@@ -168,6 +320,8 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
}
|
||||
element.addEventListener('transitionend', onEnd)
|
||||
running.set(element, cancel)
|
||||
boxes.set(element, { element, height, marginTop, closing: true, finish,
|
||||
take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } })
|
||||
}
|
||||
|
||||
function reveal(element: HTMLElement): void {
|
||||
@@ -200,16 +354,28 @@ export function createFlowMotion(reserve: (px: number) => void, foldingChanged:
|
||||
const timer = setTimeout(finish, CLOSE_DELAY_MS + CLOSE_MS + SETTLE_SLACK_MS)
|
||||
element.addEventListener('transitionend', onEnd)
|
||||
running.set(element, finish)
|
||||
boxes.set(element, { element, height, marginTop, closing: false, finish,
|
||||
take: () => { element.removeEventListener('transitionend', onEnd); clearTimeout(timer) } })
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
clearEpoch++
|
||||
reserveScheduled = false
|
||||
inputDriver = null
|
||||
activeDriver = null
|
||||
inputBatch = false
|
||||
if (inputFrame !== null) cancelAnimationFrame(inputFrame)
|
||||
inputFrame = null
|
||||
inputCleanup?.()
|
||||
inputCleanup = null
|
||||
cancelEmptyBatch = null
|
||||
foldIdleListeners.clear()
|
||||
pendingReserve.clear()
|
||||
pendingGrowth = 0
|
||||
for (const cancel of [...running.values()]) cancel()
|
||||
}
|
||||
|
||||
return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, clear }
|
||||
return { collapse, reveal, cancelMotion, foldActive, onFoldIdle, followInput, cancelInput, inputActive: () => inputBatch, clear }
|
||||
}
|
||||
|
||||
function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): HiddenTransition | undefined {
|
||||
@@ -219,11 +385,12 @@ function useFlowTransition(motion: FlowMotionRows | undefined, grow: boolean): H
|
||||
current.current = null
|
||||
}, [motion])
|
||||
const transition = useCallback<HiddenTransition>((element, hidden, commit) => {
|
||||
const animate = current.current === element && motionEnabled(element)
|
||||
const mounted = current.current === element
|
||||
const animate = mounted && motionEnabled(element)
|
||||
current.current = element
|
||||
if (hidden) {
|
||||
if (hiddenOrCollapsing(element)) return
|
||||
if (animate && motion !== undefined) motion.collapse(element, commit)
|
||||
if (mounted && element.closest(MOTION_SCOPE) !== null && motion !== undefined) motion.collapse(element, commit)
|
||||
else commit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,6 +87,17 @@ export class ChatReading {
|
||||
this.commit(landing, true, this.viewport.latestTurn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow submitted input on the active fold's animation clock.
|
||||
* @returns whether this input joined a closing batch instead of starting another scroll animation.
|
||||
*/
|
||||
followInputFold(): boolean {
|
||||
this.cancelPending()
|
||||
return this.viewport.followInputFold(this.follow, (landing) => {
|
||||
this.commit(landing, true, this.viewport.latestTurn)
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop a native follow animation where it is; the next sampled position decides follow intent. */
|
||||
interruptFollow(): void {
|
||||
this.viewport.interruptFollow(this.follow)
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
cancelFollow.current = null
|
||||
}, [])
|
||||
const cancelPendingFollow = useCallback(() => {
|
||||
viewport.motion.cancelInput()
|
||||
clearPendingFollow()
|
||||
// Retain a cancelled wait until this fold ends, so resize cannot reacquire follow ownership.
|
||||
if (viewport.motion.foldActive()) {
|
||||
@@ -71,7 +72,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
}
|
||||
}, [clearPendingFollow, viewport])
|
||||
const followAfterFold = useCallback(() => {
|
||||
if (cancelFollow.current !== null) return
|
||||
if (cancelFollow.current !== null || viewport.motion.inputActive()) return
|
||||
reading.interruptFollow()
|
||||
cancelFollow.current = viewport.motion.onFoldIdle(() => {
|
||||
cancelFollow.current = null
|
||||
@@ -100,8 +101,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
if (ownInput) {
|
||||
clearPendingFollow()
|
||||
navigation.cancel()
|
||||
if (current.deferCompletedTurns && viewport.motion.foldActive()) followAfterFold()
|
||||
else reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant')
|
||||
if (current.deferCompletedTurns && reading.followInputFold()) return
|
||||
reading.followTail(current.deferCompletedTurns ? 'smooth' : 'instant')
|
||||
return
|
||||
}
|
||||
if (navigation.contentCommitted()) {
|
||||
|
||||
@@ -160,6 +160,107 @@ export class ChatViewport {
|
||||
this.setSpacer(this.spacerHeight() + px)
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an input-caused fold and its tail movement on the fold's frame clock.
|
||||
* @param follow - the existing reader's follow controller.
|
||||
* @param settled - commits follow ownership after the final geometry is installed.
|
||||
* @returns whether a closing batch owns this input.
|
||||
*/
|
||||
followInputFold(follow: ScrollFollow, settled: (landing: ViewportLanding) => void): boolean {
|
||||
const elements = this.elements
|
||||
if (elements === null) return false
|
||||
this.interruptFollow(follow)
|
||||
let start = 0
|
||||
let target = 0
|
||||
let removed = 0
|
||||
let initialHeight = 0
|
||||
let originalRoom = 0
|
||||
let finalRoom = 0
|
||||
let growth = 0
|
||||
let base = 0
|
||||
let previousColumnHeight = 0
|
||||
let previousProgress = 0
|
||||
let segmentProgress = 0
|
||||
let segmentTop = 0
|
||||
return this.motion.followInput({
|
||||
begin: (boxes) => {
|
||||
const viewport = elements.scroller.getBoundingClientRect()
|
||||
const metrics = scrollMetrics(elements.scroller)
|
||||
start = metrics.top
|
||||
originalRoom = this.spacerHeight()
|
||||
initialHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - originalRoom)
|
||||
let above = 0
|
||||
for (const box of boxes) {
|
||||
const amount = box.height + box.marginTop
|
||||
if (box.closing) {
|
||||
removed += amount
|
||||
const top = box.element.getBoundingClientRect().top - box.marginTop
|
||||
above += Math.max(0, Math.min(amount, viewport.top - top))
|
||||
} else growth += amount
|
||||
}
|
||||
const finalHeight = initialHeight - removed + growth
|
||||
base = Math.max(0, start - above)
|
||||
segmentTop = start
|
||||
previousColumnHeight = elements.column.offsetHeight
|
||||
target = Math.max(base, Math.max(0, finalHeight - metrics.height))
|
||||
finalRoom = target === 0 ? 0 : Math.max(0, target + metrics.height - finalHeight)
|
||||
elements.column.dataset.inputFold = ''
|
||||
// This reserve only prevents an intermediate clamp; the final frame installs the exact deficit.
|
||||
this.setSpacer(Math.max(originalRoom, finalRoom, removed))
|
||||
follow.setFollowing(true)
|
||||
},
|
||||
prepare: (progress, cancelled) => {
|
||||
if (this.elements !== elements) return
|
||||
const added = elements.column.offsetHeight - previousColumnHeight
|
||||
if (added !== 0) {
|
||||
initialHeight += added
|
||||
const finalHeight = initialHeight - removed + growth
|
||||
target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0)
|
||||
finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight)
|
||||
segmentTop = elements.scroller.scrollTop
|
||||
segmentProgress = previousProgress
|
||||
}
|
||||
const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress)
|
||||
const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining
|
||||
const currentHeight = initialHeight + (growth - removed) * progress
|
||||
const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight)
|
||||
// Growing the room before shrinking boxes avoids a native clamp between writes.
|
||||
this.setSpacer(Math.max(this.spacerHeight(), room))
|
||||
},
|
||||
frame: (progress, cancelled) => {
|
||||
if (this.elements !== elements) return 0
|
||||
const remaining = segmentProgress === 1 ? 1 : (progress - segmentProgress) / (1 - segmentProgress)
|
||||
const top = cancelled ? elements.scroller.scrollTop : segmentTop + (target - segmentTop) * remaining
|
||||
const currentHeight = initialHeight + (growth - removed) * progress
|
||||
const room = Math.max(cancelled ? 0 : finalRoom, top + elements.scroller.clientHeight - currentHeight)
|
||||
elements.scroller.scrollTop = top
|
||||
this.setSpacer(room)
|
||||
if (!cancelled) this.observation = { top: elements.scroller.scrollTop, landing: null }
|
||||
previousColumnHeight = elements.column.offsetHeight
|
||||
previousProgress = progress
|
||||
return elements.scroller.scrollTop - start
|
||||
},
|
||||
finish: (cancelled) => {
|
||||
if (this.elements !== elements) return
|
||||
// Commit the final sibling margins while their input-fold transition override still applies.
|
||||
void elements.column.offsetHeight
|
||||
delete elements.column.dataset.inputFold
|
||||
if (cancelled) {
|
||||
const metrics = scrollMetrics(elements.scroller)
|
||||
const height = elements.scroller.scrollHeight - this.spacerHeight()
|
||||
this.setSpacer(metrics.top === 0 ? 0 : Math.max(0, metrics.top + metrics.height - height))
|
||||
this.invalidate()
|
||||
return
|
||||
}
|
||||
this.setSpacer(finalRoom)
|
||||
const metrics = scrollMetrics(elements.scroller)
|
||||
const landing = { metrics, position: null, turn: this.latestTurn }
|
||||
this.observation = { top: metrics.top, landing }
|
||||
settled(landing)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear fold reservations after a display-mode change, allowing the scroll range to shrink. */
|
||||
resetBelow(): void {
|
||||
this.setSpacer(0)
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
en: b25bf00ce3a6a119
|
||||
zh: 0ab47a4014ea5e1e
|
||||
/chat-conversation-node-rules/display-modes/collapse-timing:
|
||||
en: a7cd26255c76f714
|
||||
zh: c8dc2475c3ce3739
|
||||
en: d87667e4d1729e51
|
||||
zh: 014a14ebb16ac798
|
||||
/chat-conversation-node-rules/display-modes/group-title-rules:
|
||||
en: 5b40d65e660549b7
|
||||
zh: 09b6894aa00154e7
|
||||
en: 2392290662bc69e8
|
||||
zh: 8872d651c07a8f4c
|
||||
/chat-conversation-node-rules/process-grouping:
|
||||
en: c2650489ded57503
|
||||
zh: f2554d74e75ae0f9
|
||||
|
||||
@@ -140,6 +140,8 @@ Settings → General → Collapse work details appears directly below Work detai
|
||||
|
||||
With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately.
|
||||
|
||||
When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it.
|
||||
|
||||
Verbose retains its flat process rows under either timing. Failed, stopped, and interleaved-input Turns retain their existing whole-Turn eligibility rules; delayed folding can still postpone Detailed's conversion from flat rows to historical groups.
|
||||
|
||||
### Group-title rules
|
||||
@@ -183,7 +185,7 @@ Group headers show a category icon, replace it with a down arrow on hover or key
|
||||
|
||||
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
|
||||
|
||||
Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography.
|
||||
Only reasoning blocks with non-whitespace text produce individual rows; each retains its original text and expands independently. A blank streaming tail produces no row and does not transfer its running state to an earlier block. Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography.
|
||||
|
||||
Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Detailed and Verbose do not open every individual disclosure. Only Verbose keeps the whole Turn open.
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。
|
||||
|
||||
新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。
|
||||
|
||||
完全展开模式在两种时机下都保留平铺过程行。失败、停止和含插入输入的轮次沿用原有整轮折叠资格;延后折叠仍可推迟详细模式从平铺行转换为历史分组的时机。
|
||||
|
||||
### 组标题规则
|
||||
@@ -183,7 +185,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
|
||||
|
||||
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
|
||||
|
||||
单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
|
||||
只有含非空白文本的推理块生成独立行,各自保留原文并独立展开。空白流式尾块不生成行,也不将运行状态转移给前面的块。单个推理初始收起,包括流式输出期间。四档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。
|
||||
|
||||
切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。详细和完全展开都不表示自动打开每个内部控件;只有完全展开模式始终展开整轮。
|
||||
|
||||
|
||||
@@ -100,6 +100,219 @@ function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInpu
|
||||
}
|
||||
}
|
||||
|
||||
const visibleFoldCases = [
|
||||
{ name: 'E/E', earlier: 2400, steps: 1000, body: 400, viewport: 800, top: 800, input: 80, target: 2080, room: 0 },
|
||||
{ name: 'E/T', earlier: 600, steps: 1000, body: 100, viewport: 800, top: 200, input: 80, target: 200, room: 220 },
|
||||
{ name: 'E/B', earlier: 500, steps: 200, body: 400, viewport: 800, top: 200, input: 80, target: 200, room: 20 },
|
||||
{ name: 'T/T', earlier: 500, steps: 2000, body: 100, viewport: 800, top: 900, input: 80, target: 500, room: 620 },
|
||||
{ name: 'T/B', earlier: 500, steps: 1200, body: 400, viewport: 800, top: 1200, input: 80, target: 500, room: 320 },
|
||||
{ name: 'B/B', earlier: 2400, steps: 1600, body: 1000, viewport: 800, top: 4200, input: 80, target: 2680, room: 0 },
|
||||
{ name: 'short content', earlier: 100, steps: 300, body: 100, viewport: 800, top: 0, input: 80, target: 0, room: 0 },
|
||||
{ name: 'larger input', earlier: 2000, steps: 300, body: 600, viewport: 800, top: 2100, input: 400, target: 2200, room: 0 },
|
||||
{ name: 'body fills viewport', earlier: 900, steps: 300, body: 800, viewport: 800, top: 1200, input: 40, target: 940, room: 0 },
|
||||
{ name: 'steps fill viewport', earlier: 900, steps: 800, body: 0, viewport: 800, top: 900, input: 0, target: 900, room: 800 },
|
||||
{ name: 'input fills visible steps', earlier: 900, steps: 300, body: 500, viewport: 800, top: 900, input: 300, target: 900, room: 0 },
|
||||
{ name: 'no steps', earlier: 900, steps: 0, body: 500, viewport: 800, top: 600, input: 40, target: 640, room: 0 },
|
||||
{ name: 'input taller than viewport', earlier: 900, steps: 1200, body: 100, viewport: 800, top: 1400, input: 1200, target: 1400, room: 0 },
|
||||
{ name: 'small viewport', earlier: 200, steps: 900, body: 100, viewport: 300, top: 900, input: 30, target: 200, room: 170 },
|
||||
] as const
|
||||
|
||||
function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean) {
|
||||
let inputHeight = 0
|
||||
let submission = 0
|
||||
let top: number = test.top
|
||||
let motion: FlowMotionRows | undefined
|
||||
const onMotion = (value: FlowMotionRows): void => { motion = value }
|
||||
let input: ChatScrollInput = {
|
||||
ready: false, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
|
||||
steeringId: null, submissionId: null, running: false, deferCompletedTurns: true,
|
||||
transcriptView: 'detailed', loadedTurns: [], hasMore: false, loadingOlder: false,
|
||||
loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() },
|
||||
}
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced })))
|
||||
const view = render(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
const scroller = within(view.container).getByTestId('scrollport')
|
||||
const column = within(view.container).getByTestId('column')
|
||||
const spacer = scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
|
||||
const row = document.createElement('div')
|
||||
const glyph = document.createElement('span')
|
||||
glyph.textContent = 'step text'
|
||||
row.append(glyph)
|
||||
column.append(row)
|
||||
const rowHeight = () => row.hidden ? 0 : (row.style.transition !== '' && row.style.transition !== 'none') || row.style.height === ''
|
||||
? test.steps : Number.parseFloat(row.style.height)
|
||||
const room = () => Number.parseFloat(spacer.style.height) || 0
|
||||
const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight
|
||||
const floor = () => Math.max(0, contentHeight() + room() - test.viewport)
|
||||
const scrollTo = vi.fn((options: ScrollToOptions) => {
|
||||
scroller.scrollTop = options.top ?? scroller.scrollTop
|
||||
})
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { get: () => test.viewport },
|
||||
scrollHeight: { get: () => Math.max(test.viewport, contentHeight() + room()) },
|
||||
scrollTop: {
|
||||
get: () => { top = Math.max(0, Math.min(top, floor())); return top },
|
||||
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
|
||||
},
|
||||
scrollTo: { value: scrollTo },
|
||||
getBoundingClientRect: { value: () => ({ top: 0, bottom: test.viewport, height: test.viewport }) },
|
||||
})
|
||||
Object.defineProperties(row, {
|
||||
offsetHeight: { get: rowHeight },
|
||||
getBoundingClientRect: { value: () => ({ top: test.earlier - top, bottom: test.earlier - top + rowHeight(), height: rowHeight() }) },
|
||||
})
|
||||
Object.defineProperties(column, {
|
||||
offsetHeight: { get: contentHeight },
|
||||
getBoundingClientRect: { value: () => ({ top: -top, bottom: contentHeight() - top, height: contentHeight() }) },
|
||||
})
|
||||
input = { ...input, ready: true }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
top = test.top
|
||||
scrollTo.mockClear()
|
||||
return {
|
||||
scroller, row, room, scrollTo,
|
||||
reopenAll(verbose = false) {
|
||||
act(() => { motion?.cancelMotion(row) })
|
||||
row.hidden = false
|
||||
if (verbose) {
|
||||
input = { ...input, transcriptView: 'verbose' }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
}
|
||||
},
|
||||
growDuringFold(height: number, newSubmission: boolean) {
|
||||
inputHeight += height
|
||||
input = newSubmission
|
||||
? { ...input, submissionId: 'second-distinct-input' }
|
||||
: { ...input, order: ['new-stream-content'], running: true }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
},
|
||||
cancelReading() {
|
||||
fireEvent.wheel(scroller, { deltaY: -20 })
|
||||
scroller.scrollTop -= 20
|
||||
fireEvent.scroll(scroller)
|
||||
return scroller.scrollTop
|
||||
},
|
||||
updateInput() {
|
||||
input = { ...input, submissionId: null, lastKey: 'durable-input', lastIsUser: true }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
},
|
||||
bodyTop: () => test.earlier + rowHeight() - top,
|
||||
submit() {
|
||||
inputHeight += test.input
|
||||
row.hidden = false
|
||||
submission++
|
||||
act(() => {
|
||||
motion?.collapse(row, () => { row.hidden = true })
|
||||
})
|
||||
input = { ...input, submissionId: `visible-fold-input-${submission}` }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([false, true])('visible input-fold matrix (reduced=%s)', (reduced) => {
|
||||
it.each(visibleFoldCases)('$name lands with only the required visible room', async (test) => {
|
||||
const h = mountVisibleFold(test, reduced)
|
||||
const positions = [h.bodyTop()]
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
for (let frame = 0; frame < 24; frame++) {
|
||||
act(() => { vi.advanceTimersByTime(16) })
|
||||
positions.push(h.bodyTop())
|
||||
}
|
||||
expect(h.scroller.scrollTop).toBeCloseTo(test.target, 5)
|
||||
expect(h.room()).toBeCloseTo(test.room, 5)
|
||||
if (test.steps > 0) expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false)
|
||||
for (let i = 1; i < positions.length; i++) expect(positions[i]!).toBeLessThanOrEqual(positions[i - 1]! + 0.01)
|
||||
const end = h.bodyTop()
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(h.bodyTop()).toBe(end)
|
||||
})
|
||||
})
|
||||
|
||||
it('consumes a prior legal fold deficit instead of accumulating whole steps on the next input', async () => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'T/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.room()).toBe(320)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scroller.scrollTop).toBe(500)
|
||||
expect(h.room()).toBe(240)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the reader position without clamping after an input fold is interrupted', async () => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'B/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(112) })
|
||||
const readingTop = h.cancelReading()
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scroller.scrollTop).toBe(readingTop)
|
||||
expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('stops obsolete viewport writes when every closing row reopens (verbose=%s)', async (verbose) => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'T/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(112) })
|
||||
h.reopenAll(verbose)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const position = h.scroller.scrollTop
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.room()).toBe(0)
|
||||
expect(h.scroller.scrollTop).toBe(position)
|
||||
expect(h.row.hidden).toBe(false)
|
||||
expect(h.row.style.height).toBe('')
|
||||
expect(h.scroller.querySelector('[data-input-fold]')).toBeNull()
|
||||
expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('absorbs new height before the active fold finishes (new submission=%s)', async (newSubmission) => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'B/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(112) })
|
||||
h.growDuringFold(320, newSubmission)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const positions = [h.bodyTop()]
|
||||
for (let frame = 0; frame < 12; frame++) {
|
||||
act(() => { vi.advanceTimersByTime(16) })
|
||||
positions.push(h.bodyTop())
|
||||
}
|
||||
expect(h.scroller.scrollTop).toBe(test.target + 320)
|
||||
expect(h.room()).toBe(0)
|
||||
for (let i = 1; i < positions.length; i++) expect(positions[i]!).toBeLessThanOrEqual(positions[i - 1]! + 0.01)
|
||||
const settled = h.scroller.scrollTop
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(h.scroller.scrollTop).toBe(settled)
|
||||
expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps one active fold when the local echo becomes its durable input', async () => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'B/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(96) })
|
||||
const halfway = h.bodyTop()
|
||||
h.updateInput()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scroller.scrollTop).toBe(test.target)
|
||||
expect(h.room()).toBe(0)
|
||||
expect(h.bodyTop()).toBeLessThanOrEqual(halfway)
|
||||
expect(h.scrollTo.mock.calls.some(([options]) => options.behavior === 'smooth')).toBe(false)
|
||||
})
|
||||
|
||||
describe('Chat scroll collapse timing', () => {
|
||||
it.each(['resize', 'scroll', 'history', 'input', 'preference'] as const)(
|
||||
'waits for initial history before initializing a running view after %s', (change) => {
|
||||
@@ -148,37 +361,29 @@ describe('Chat scroll collapse timing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces submitted inputs into one follow after the last row closes', () => {
|
||||
it('coalesces submitted inputs into one frame-owned fold', async () => {
|
||||
const h = mountScroll()
|
||||
h.startFold()
|
||||
h.update({ submissionId: 'first' })
|
||||
h.update({ submissionId: 'second' })
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
expect(h.scroller.scrollTop).toBe(0)
|
||||
h.finishFold()
|
||||
expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
expect(h.scroller.scrollTop).toBe(520)
|
||||
expect(h.scroller.style.overflowAnchor).toBe('')
|
||||
})
|
||||
|
||||
it('keeps excess collapse as bottom room without reversing the scrollport', async () => {
|
||||
const h = mountScroll()
|
||||
h.scroller.scrollTop = 600
|
||||
h.startFold()
|
||||
it('keeps only the visible deficit when submitted input is smaller than the fold', async () => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'T/B')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
h.grow(1_060)
|
||||
h.update({ submissionId: 'new-input' })
|
||||
h.grow(1_020)
|
||||
act(() => { vi.advanceTimersByTime(20) })
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
h.grow(980)
|
||||
act(() => { vi.advanceTimersByTime(20) })
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
fireEvent.scroll(h.scroller)
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
h.finishFold()
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scroller.scrollTop).toBe(500)
|
||||
expect(h.room()).toBe(320)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height).toBe('20px')
|
||||
})
|
||||
|
||||
it.each([false, true])('clears mode-change bottom space after the fold (reader interrupts=%s)', async (interrupt) => {
|
||||
@@ -231,18 +436,15 @@ describe('Chat scroll collapse timing', () => {
|
||||
expect(h.scroller.scrollTop).toBe(520)
|
||||
})
|
||||
|
||||
it('follows only the distance left after a smaller fold', async () => {
|
||||
const h = mountScroll()
|
||||
h.scroller.scrollTop = 600
|
||||
h.startFold()
|
||||
it('combines the remaining input distance with the smaller fold', async () => {
|
||||
const test = visibleFoldCases.find(test => test.name === 'larger input')!
|
||||
const h = mountVisibleFold(test, false)
|
||||
h.submit()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
h.grow(1_200)
|
||||
h.update({ submissionId: 'new-input' })
|
||||
h.grow(1_120)
|
||||
act(() => { vi.advanceTimersByTime(20) })
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
h.finishFold()
|
||||
expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 720, behavior: 'smooth' })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
expect(h.scroller.scrollTop).toBe(2200)
|
||||
expect(h.room()).toBe(0)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['wheel', 'touchstart', 'pointerdown', 'keydown'] as const)(
|
||||
@@ -275,7 +477,9 @@ describe('Chat scroll collapse timing', () => {
|
||||
h.update({ order: ['streaming-change'], running: true })
|
||||
if (newInput) h.update({ submissionId: 'second' })
|
||||
h.finishFold()
|
||||
if (newInput) expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
if (newInput) expect(h.scroller.scrollTop).toBe(520)
|
||||
else {
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
expect(h.scroller.scrollTop).toBe(420)
|
||||
|
||||
@@ -210,6 +210,22 @@ describe('flow motion', () => {
|
||||
expect(reserve).toHaveBeenCalledExactlyOnceWith(200)
|
||||
})
|
||||
|
||||
it('does not rewrite a reopened row after its input-fold ownership is cancelled', async () => {
|
||||
vi.useFakeTimers()
|
||||
const row = rowWithHeight(180)
|
||||
const done = vi.fn()
|
||||
motion.collapse(row, done)
|
||||
motion.followInput({ begin: vi.fn(), frame: () => -20, finish: vi.fn() })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(96)
|
||||
motion.cancelMotion(row)
|
||||
expectCleared(row)
|
||||
vi.advanceTimersByTime(400)
|
||||
expectCleared(row)
|
||||
expect(row.style.translate).toBe('')
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending reservations and idle work with its owner', async () => {
|
||||
const row = rowWithHeight(80)
|
||||
const idle = vi.fn()
|
||||
@@ -307,13 +323,14 @@ describe.each([
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([false, true])('hides immediately without motion when reduced motion is %s', (reduced) => {
|
||||
it.each([false, true])('hides before the next paint when reduced motion is %s', async (reduced) => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced })))
|
||||
const onReveal = vi.fn()
|
||||
const view = render(<div data-chat-motion={reduced ? '' : undefined}><Component motion={motion} hidden={false} onReveal={onReveal} /></div>)
|
||||
const row = view.getByTestId('motion-row')
|
||||
Object.defineProperty(row, 'offsetHeight', { value: 80 })
|
||||
view.rerender(<div data-chat-motion={reduced ? '' : undefined}><Component motion={motion} hidden onReveal={onReveal} /></div>)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(row.getAttribute('hidden')).toBe(hiddenValue)
|
||||
expectCleared(row)
|
||||
expect(motion.foldActive()).toBe(false)
|
||||
|
||||
@@ -21,6 +21,119 @@ afterEach(() => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
|
||||
|
||||
describe('AssistantMarkdown reasoning blocks', () => {
|
||||
const props = {
|
||||
renderSlot, useDisclosure, useGroupAction: useSearchableHidden,
|
||||
usePresentation: useDetailedPresentation, renderMessageImages, t,
|
||||
}
|
||||
|
||||
it.each([false, true])('omits blank blocks without trimming body text when streaming=%s', (streaming) => {
|
||||
const text = '\n **Visible reasoning**\n\nOriginal body \n'
|
||||
const texts = ['', ' \t\r\n', text, '']
|
||||
const blocks = Object.freeze(texts.map(text => Object.freeze({ kind: 'reasoning' as const, text })))
|
||||
const calls = vi.fn()
|
||||
const extension: AssistantMarkdownProps['renderSlot'] = (key, owner, options) => {
|
||||
calls(key, owner)
|
||||
return renderSlot(key, owner, options)
|
||||
}
|
||||
const view = render(<AssistantMarkdown {...props} renderSlot={extension} blocks={blocks} streaming={streaming} />)
|
||||
expect(view.getAllByRole('button')).toHaveLength(1)
|
||||
expect(view.container.querySelectorAll('[data-variant="think"]')).toHaveLength(1)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(calls).toHaveBeenCalledWith('conversation.chat.reasoning.body', { text, running: false })
|
||||
expect(view.getByText('Visible reasoning').tagName).toBe('STRONG')
|
||||
expect(blocks.map(block => block.text)).toEqual(texts)
|
||||
})
|
||||
|
||||
it.each([false, true])('waits for non-whitespace reasoning when streaming=%s', (streaming) => {
|
||||
const view = render(<AssistantMarkdown {...props} blocks={[{ kind: 'reasoning', text: '' }]} streaming={streaming} />)
|
||||
expect(view.queryAllByRole('button')).toHaveLength(0)
|
||||
for (const text of [' ', '\t', '\r\n', ' \t\r\n', '\u00a0']) {
|
||||
view.rerender(<AssistantMarkdown {...props} blocks={[{ kind: 'reasoning', text }]} streaming={streaming} />)
|
||||
expect(view.queryAllByRole('button')).toHaveLength(0)
|
||||
}
|
||||
view.rerender(<AssistantMarkdown {...props} blocks={[{ kind: 'reasoning', text: '\n First thought' }]} streaming={streaming} />)
|
||||
expect(view.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('retains an expanded earlier row when a blank streaming tail receives text', () => {
|
||||
const first = { kind: 'reasoning' as const, text: 'First thought\n\nEarlier body' }
|
||||
const blank = { kind: 'reasoning' as const, text: ' \t\r\n' }
|
||||
const view = render(<AssistantMarkdown {...props} blocks={[first, blank, blank]} streaming />)
|
||||
const earlierToggle = view.getByRole('button')
|
||||
const earlierRow = earlierToggle.closest('[data-variant="think"]')
|
||||
expect(earlierRow?.getAttribute('data-state')).toBe('ok')
|
||||
expect(view.queryByText('运行中')).toBeNull()
|
||||
fireEvent.click(earlierToggle)
|
||||
const earlierBody = view.getByText('Earlier body')
|
||||
|
||||
const tail = { kind: 'reasoning' as const, text: 'An unfinished tail' }
|
||||
const blocks = [first, blank, tail]
|
||||
view.rerender(<AssistantMarkdown {...props} blocks={blocks} streaming />)
|
||||
const toggles = view.getAllByRole('button')
|
||||
expect(toggles).toHaveLength(2)
|
||||
expect(toggles[0]).toBe(earlierToggle)
|
||||
expect(view.getByText('Earlier body')).toBe(earlierBody)
|
||||
expect(earlierToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(earlierRow?.getAttribute('data-state')).toBe('ok')
|
||||
const tailToggle = toggles[1]!
|
||||
const tailRow = tailToggle.closest('[data-variant="think"]')
|
||||
expect(tailRow?.getAttribute('data-state')).toBe('running')
|
||||
expect(view.queryByText(tail.text)).toBeNull()
|
||||
fireEvent.click(tailToggle)
|
||||
expect(view.getByText(tail.text)).toBeTruthy()
|
||||
expect(earlierToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
view.rerender(<AssistantMarkdown {...props} blocks={blocks} streaming={false} />)
|
||||
const settledToggles = view.getAllByRole('button')
|
||||
expect(settledToggles).toHaveLength(2)
|
||||
expect(settledToggles[0]).toBe(earlierToggle)
|
||||
expect(settledToggles[1]).toBe(tailToggle)
|
||||
expect(view.getByText('Earlier body')).toBe(earlierBody)
|
||||
expect(tailRow?.getAttribute('data-state')).toBe('ok')
|
||||
expect(tailToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(tailToggle)
|
||||
expect(earlierToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(tailToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it.each(['Partial thought', '', ' \t\r\n'])('retains the stopped marker with blank reasoning around %j', (text) => {
|
||||
const blocks = ['', text, ' \t\r\n'].map(text => ({ kind: 'reasoning' as const, text }))
|
||||
const view = render(<AssistantMarkdown {...props} blocks={blocks} groupPart="reasoning" streaming={false} interrupted />)
|
||||
expect(view.queryAllByRole('button')).toHaveLength(text.trim() === '' ? 0 : 1)
|
||||
expect(view.getByText(t('message.stopped'))).toBeTruthy()
|
||||
expect(view.queryByText('运行中')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps non-blank rows and their expanded bodies across modes, excluding them from the response part', () => {
|
||||
const mode = createSnapshotStore<TranscriptViewMode>('compact')
|
||||
const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode, createSnapshotStore<CollapseTiming>('completion')))
|
||||
const blocks: AssistantMarkdownProps['blocks'] = [
|
||||
{ kind: 'reasoning', text: '' },
|
||||
{ kind: 'reasoning', text: 'Mode thought\n\nMode body' },
|
||||
{ kind: 'reasoning', text: ' \t\r\n' },
|
||||
{ kind: 'text', text: 'Answer' },
|
||||
]
|
||||
const view = render(<AssistantMarkdown {...props} usePresentation={usePresentation}
|
||||
blocks={blocks} groupPart="reasoning" streaming={false} />)
|
||||
const toggle = view.getByRole('button')
|
||||
fireEvent.click(toggle)
|
||||
const body = view.getByText('Mode body')
|
||||
for (const next of ['standard', 'detailed', 'verbose', 'compact'] as const) {
|
||||
act(() => { mode.set(next) })
|
||||
expect(view.getAllByRole('button')).toHaveLength(1)
|
||||
expect(view.getByRole('button')).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('Mode body')).toBe(body)
|
||||
expect(view.queryByText('Answer')).toBeNull()
|
||||
}
|
||||
view.rerender(<AssistantMarkdown {...props} usePresentation={usePresentation}
|
||||
blocks={blocks} groupPart="response" streaming={false} />)
|
||||
expect(view.queryAllByRole('button')).toHaveLength(0)
|
||||
expect(view.getByText('Answer')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReasoningRow', () => {
|
||||
it('offers only text and streaming state to the standard Body Slot after expansion', () => {
|
||||
const calls = vi.fn()
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
en: 8bbfa14c27f3bfd7
|
||||
zh: 88f7548ac3a114e1
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/geometry-and-overflow:
|
||||
en: e283bf2366b43879
|
||||
zh: d089076d7834e853
|
||||
en: 45f4cd1ef96bb6d2
|
||||
zh: f264d25b780ad1dc
|
||||
/deepseek-ai-dsh-client-ui-primitives/further-exploration:
|
||||
en: e82b713cdb2f954b
|
||||
zh: a877f34b148f433f
|
||||
|
||||
@@ -161,7 +161,7 @@ While a reply streams, `MarkdownText` parses incrementally: all but the trailing
|
||||
|
||||
### Geometry and overflow
|
||||
|
||||
The output cards share one geometry model: `white-space: pre` with horizontal scrolling so column-aligned content keeps its alignment, and a head-plus-tail slice behind an expand button past `maxLines` (default 16) so a long body never stretches the card. `TerminalBlock` uses `CommandText` to keep each authored command line selectable and horizontally scrollable; the prompt and status stay fixed. Only horizontally overflowing text enters the Tab order as a named group, using the owner’s localized `commandLine(line)` label. Text that fits, including wrapped Jobs commands, adds no Tab stop. Size, text, and font changes update focusability without changing the scroll position. `TerminalBlock` parses ANSI into React spans with a per-line column buffer for cursor movement, honoring erase-in-line, tab stops, and character width. Hosts opt out of the shared geometry per surface: rebinding `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` to `pre-wrap` wraps commands and output in full with no sideways scroll, `maxLines: Infinity` disables the fold for hosts capping height through `--dsl-terminal-output-max-height` instead, `copyText` overrides the copy payload (and keeps the control rendered before any output), and `runStateDot: false` omits the run-state dot when the surrounding row already carries the state, reclaiming its gutter via `--dsl-terminal-gutter`. The banner divider follows the rendered body, so a running card that streams live output separates its command from the text like a settled card.
|
||||
The output cards share one geometry model: `white-space: pre` with horizontal scrolling so column-aligned content keeps its alignment, and a head-plus-tail slice behind an expand button past `maxLines` (default 16) so a long body never stretches the card. `TerminalBlock` uses `CommandText` to keep each authored command line selectable and horizontally scrollable; the prompt and status stay fixed. The run-state dot aligns with the first visual command line, including when the command wraps. Only horizontally overflowing text enters the Tab order as a named group, using the owner’s localized `commandLine(line)` label. Text that fits, including wrapped Jobs commands, adds no Tab stop. Size, text, and font changes update focusability without changing the scroll position. `TerminalBlock` parses ANSI into React spans with a per-line column buffer for cursor movement, honoring erase-in-line, tab stops, and character width. Hosts opt out of the shared geometry per surface: rebinding `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` to `pre-wrap` wraps commands and output in full with no sideways scroll, `maxLines: Infinity` disables the fold for hosts capping height through `--dsl-terminal-output-max-height` instead, `copyText` overrides the copy payload (and keeps the control rendered before any output), and `runStateDot: false` omits the run-state dot when the surrounding row already carries the state, reclaiming its gutter via `--dsl-terminal-gutter`. `background` defaults to false and carries the call's background flag. For a background call, `running` indicates launch-call progress: Start shows that indicator; the acknowledgement sets `running` to false and leaves the command static without a state dot or assistive label. Background calls omit output, empty-output placeholders, exit pills, and the default output Copy control; an explicit `copyText` override remains available. The banner divider follows the rendered body, so a running card that streams live output separates its command from the text like a settled card.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ kind: "package-library"
|
||||
|
||||
### 几何与溢出
|
||||
|
||||
输出卡片共享同一套几何模型:`white-space: pre` 并横向滚动,让按列对齐的内容保持对齐;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制,长正文不会撑高卡片。`TerminalBlock` 通过 `CommandText` 让每条原始命令行可选中并横向滚动;提示符和状态保持固定。只有横向溢出的文本以具名 group 进入 Tab 顺序,名称使用 owner 的本地化 `commandLine(line)` 标签。能完整显示的文本(包括 Jobs 中已换行的命令)不会添加 Tab 停靠点。尺寸、文本与字体变化会更新可聚焦状态,不改变滚动位置。`TerminalBlock` 把 ANSI 解析为 React span,并带逐行列缓冲处理光标移动,遵循行内擦除、制表位与字符宽度。宿主可按表层选择退出共享几何:把 `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` 重绑为 `pre-wrap` 让命令与输出完整换行且不横向滚动;`maxLines: Infinity` 为改用 `--dsl-terminal-output-max-height` 限高滚动的宿主禁用折叠;`copyText` 覆盖复制载荷(并让控件在任何输出出现之前就保持渲染);`runStateDot: false` 在外围行已携带同一状态时省去状态点,并经 `--dsl-terminal-gutter` 收回其落区。横幅分割线跟随渲染出的正文:正在流式输出的 running 卡片像已结束卡片一样把命令与文本分隔开。
|
||||
输出卡片共享同一套几何模型:`white-space: pre` 并横向滚动,让按列对齐的内容保持对齐;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制,长正文不会撑高卡片。`TerminalBlock` 通过 `CommandText` 让每条原始命令行可选中并横向滚动;提示符和状态保持固定。状态点始终对齐命令的第一个视觉行,包括命令自动折行时。只有横向溢出的文本以具名 group 进入 Tab 顺序,名称使用 owner 的本地化 `commandLine(line)` 标签。能完整显示的文本(包括 Jobs 中已换行的命令)不会添加 Tab 停靠点。尺寸、文本与字体变化会更新可聚焦状态,不改变滚动位置。`TerminalBlock` 把 ANSI 解析为 React span,并带逐行列缓冲处理光标移动,遵循行内擦除、制表位与字符宽度。宿主可按表层选择退出共享几何:把 `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` 重绑为 `pre-wrap` 让命令与输出完整换行且不横向滚动;`maxLines: Infinity` 为改用 `--dsl-terminal-output-max-height` 限高滚动的宿主禁用折叠;`copyText` 覆盖复制载荷(并让控件在任何输出出现之前就保持渲染);`runStateDot: false` 在外围行已携带同一状态时省去状态点,并经 `--dsl-terminal-gutter` 收回其落区。`background` 默认为 false,透传调用的后台标志。后台调用的 `running` 表示启动调用进度:Start 显示该指示,回执使 `running` 变为 false,命令保持静态且不显示状态点或辅助标签。后台调用不显示输出、空输出提示、退出状态 pill 或默认输出 Copy 控件,显式 `copyText` 覆盖仍可使用。横幅分割线跟随渲染出的正文:正在流式输出的 running 卡片像已结束卡片一样把命令与文本分隔开。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -87,11 +87,11 @@
|
||||
/* Out of flow inside the card's own gutter padding, so the reservation and the
|
||||
dot move together and no consumer margin can pull them apart; the dot neither
|
||||
indents its command nor depends on the command's text metrics to line up.
|
||||
Centered against the row's line box, not the code font's baseline. */
|
||||
Centered against the first visual line box, not the code font's baseline. */
|
||||
.runState {
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
|
||||
top: 50%;
|
||||
top: calc(var(--dsl-terminal-line-height) / 2);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@ export interface TerminalBlockProps {
|
||||
* line alone; with output it renders the live text under the running state.
|
||||
*/
|
||||
running?: boolean | undefined
|
||||
/**
|
||||
* The call launches background work (default false). `running` describes the
|
||||
* launch call only; after it returns, retain the command without job status
|
||||
* or output. An explicit `copyText` override remains available.
|
||||
*/
|
||||
background?: boolean | undefined
|
||||
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
|
||||
maxLines?: number | undefined
|
||||
/**
|
||||
@@ -172,6 +178,7 @@ export function TerminalBlock({
|
||||
exitCode,
|
||||
signal,
|
||||
running = false,
|
||||
background = false,
|
||||
maxLines = DEFAULT_TERMINAL_MAX_LINES,
|
||||
copyText,
|
||||
runStateDot = true,
|
||||
@@ -201,8 +208,9 @@ export function TerminalBlock({
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const status = statusText(exitCode, signal, copy)
|
||||
const status = background ? undefined : statusText(exitCode, signal, copy)
|
||||
const state = runState(running, exitCode, signal, copy)
|
||||
const showState = runStateDot && (!background || running)
|
||||
// A multi-line command gets one prompt row per line, so a two-command shell
|
||||
// snippet reads as the two commands it is instead of collapsing into one
|
||||
// ellipsized row. A trailing newline is a terminator, not an empty command.
|
||||
@@ -219,7 +227,7 @@ export function TerminalBlock({
|
||||
const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded)
|
||||
// A running command with nothing printed yet is banner-only; once it has
|
||||
// printed, the output streams in under the banner as it would in a terminal.
|
||||
const body = !running || !empty
|
||||
const body = !background && (!running || !empty)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -230,14 +238,14 @@ export function TerminalBlock({
|
||||
>
|
||||
<div className={css.header}>
|
||||
<div className={css.prompt}>
|
||||
{runStateDot && <span className={css.runStateLabel}>{state.label}</span>}
|
||||
{showState && <span className={css.runStateLabel}>{state.label}</span>}
|
||||
{commandLines.map((line, index) => (
|
||||
<div key={index} className={css.promptLine}>
|
||||
{/* One dot for the card, on the first row: the exit status the
|
||||
view carries is the whole call's, and bash reports no
|
||||
per-command status, so a dot per row would assert a
|
||||
per-line outcome nothing here knows. */}
|
||||
{index === 0 && runStateDot && <StateDot state={state.state} className={css.runState} />}
|
||||
{index === 0 && showState && <StateDot state={state.state} className={css.runState} />}
|
||||
{/* The cwd labels the CALL, so only its first row carries it. The
|
||||
view knows one working directory — where the call started —
|
||||
and a later line may well run somewhere else (a `cd` in the
|
||||
@@ -252,7 +260,7 @@ export function TerminalBlock({
|
||||
))}
|
||||
</div>
|
||||
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
|
||||
{(copyText !== undefined || (!running && !empty)) && (
|
||||
{(copyText !== undefined || (!background && !running && !empty)) && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? copy.copied : copy.copy}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Terminal status positioning relative to the command's first visual line. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/TerminalBlock.module.css', import.meta.url)), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
it('centers the status dot on one command line rather than the wrapped prompt height', () => {
|
||||
const state = /\.runState\s*\{([^{}]*)\}/.exec(css)?.[1]
|
||||
const prompt = /\.promptLine\s*\{([^{}]*)\}/.exec(css)?.[1]
|
||||
expect(state).toBeDefined()
|
||||
expect(prompt).toBeDefined()
|
||||
expect(state).toContain('top: calc(var(--dsl-terminal-line-height) / 2)')
|
||||
expect(state).toContain('transform: translateY(-50%)')
|
||||
expect(prompt).toContain('line-height: var(--dsl-terminal-line-height)')
|
||||
})
|
||||
@@ -89,6 +89,42 @@ describe('TerminalBlock prompt label', () => {
|
||||
})
|
||||
|
||||
describe('TerminalBlock states', () => {
|
||||
it.each([
|
||||
{ output: undefined, running: false, exitCode: undefined },
|
||||
{ output: 'launch acknowledgement', running: false, exitCode: 7, signal: 'SIGTERM' },
|
||||
{ output: 'launch acknowledgement', running: false, exitCode: 0 },
|
||||
{ output: '', running: false, exitCode: 0 },
|
||||
])('background suppresses execution presentation for %j', (execution) => {
|
||||
const view = render(<TerminalBlock command={'worker --watch\nprintf ready\n'} cwd="/workspace/app" background {...execution} />)
|
||||
expect(promptRows(view.container)).toEqual(['appworker --watch', '$printf ready'])
|
||||
expect(runStateOf(view.container)).toEqual({ state: null, label: undefined })
|
||||
expect(outputLines(view.container)).toEqual([])
|
||||
expect(view.queryByText('launch acknowledgement')).toBeNull()
|
||||
expect(view.queryByText('无输出')).toBeNull()
|
||||
expect(view.queryByText('信号 SIGTERM')).toBeNull()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false)
|
||||
expect(view.container.firstElementChild?.hasAttribute('data-body')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps command and prompt DOM when a background launch returns', () => {
|
||||
const view = render(<TerminalBlock command="worker --watch" cwd="/workspace/app" background running />)
|
||||
const command = view.container.querySelector('[data-command-text]')
|
||||
expect(runStateOf(view.container).state).toBe('ongoing')
|
||||
view.rerender(<TerminalBlock command="worker --watch" cwd="/workspace/app" background />)
|
||||
expect(view.container.querySelector('[data-command-text]')).toBe(command)
|
||||
expect(runStateOf(view.container)).toEqual({ state: null, label: undefined })
|
||||
expect(view.queryByText('无输出')).toBeNull()
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an explicit copyText override available in background mode', () => {
|
||||
const view = render(<TerminalBlock command="worker" background copyText="explicit copy" />)
|
||||
expect(view.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(view.queryByText('无输出')).toBeNull()
|
||||
expect(runStateOf(view.container)).toEqual({ state: null, label: undefined })
|
||||
})
|
||||
|
||||
it('running with no output shows the command line only: no output box, no placeholder, no copy', () => {
|
||||
const view = render(<TerminalBlock command="sleep 5" running />)
|
||||
expect(view.getByText('sleep 5')).toBeTruthy()
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 0dd8c3a22e28aacf
|
||||
zh: c27c11bf4b5cceba
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers:
|
||||
en: a8eaf01a3a3390ee
|
||||
zh: ddea36dbcc4a0159
|
||||
en: 54a50f4a3a6e4d45
|
||||
zh: af905ed570812432
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
|
||||
en: 949156aa02ec8b07
|
||||
zh: d3675754e2a37b5d
|
||||
en: 57942270e23d3a8e
|
||||
zh: 6617124e652b3f67
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
|
||||
en: b056bbe0f74fe328
|
||||
zh: fd9ac88f08825afc
|
||||
|
||||
@@ -28,7 +28,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo
|
||||
<a id="what-it-registers"></a>
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
|
||||
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
|
||||
|
||||
@@ -39,7 +39,7 @@ Document implementations register metadata with `ctx.documentPreviews.register({
|
||||
<a id="addresses"></a>
|
||||
## Addresses
|
||||
|
||||
A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address.
|
||||
A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. Routing preserves `.` and `..` path segments in the address and its content identity; the Host resolves them. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address.
|
||||
|
||||
After all text pages load, Markdown images use the authenticated `/api/file` route for absolute file paths and paths relative to the source document's directory. Relative images wait for the Host's absolute document path. URL escapes are decoded once; query and fragment suffixes are excluded from the filename. HTTP(S) images retain their authored URLs, and failed image loads show alt text. Local images require an HTTP(S) application base URL; image files are not added to automatic-refresh dependencies.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ kind: "package-reference"
|
||||
<a id="what-it-registers"></a>
|
||||
## 注册了什么
|
||||
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
|
||||
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
<a id="addresses"></a>
|
||||
## 地址
|
||||
|
||||
tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。
|
||||
tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。路由在地址及其内容身份中保留 `.` 与 `..` 路径段,由 Host 解析。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。
|
||||
|
||||
全部文本分页加载完成后,Markdown 图片通过已鉴权的 `/api/file` 路由读取绝对文件路径,以及相对于源文档所在目录的路径。相对图片等待 Host 返回文档绝对路径。URL 转义只解码一次;查询参数与片段后缀不计入文件名。HTTP(S) 图片保留原始 URL,加载失败的图片显示 alt 文本。本地图片要求应用基址为 HTTP(S) URL;图片文件不加入自动刷新依赖。
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export function textDefinition(): SidebarRightTabDefinition {
|
||||
return {
|
||||
id: TEXTPREVIEW_ID,
|
||||
kind: TEXTPREVIEW_KIND,
|
||||
patterns: ['dsh-resource://file/**'],
|
||||
// A character-class repetition includes literal dot segments that globstar excludes.
|
||||
patterns: ['dsh-resource://file/[\\s\\S]+'],
|
||||
priority: 'fallback',
|
||||
canOpen: address => parseFileAddress(address)?.scope === 'session',
|
||||
title: basenameOf,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { parseFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
|
||||
import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND, basenameOf, textDefinition } from '../src/client/definition.ts'
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('textDefinition', () => {
|
||||
const definition = textDefinition()
|
||||
expect(definition.id).toBe(TEXTPREVIEW_ID)
|
||||
expect(definition.kind).toBe(TEXTPREVIEW_KIND)
|
||||
expect(definition.patterns).toEqual(['dsh-resource://file/**'])
|
||||
expect(definition.patterns).toEqual(['dsh-resource://file/[\\s\\S]+'])
|
||||
expect(definition.priority).toBe('fallback')
|
||||
expect(definition.title('dsh-resource://file/session/s-1/work/README.md')).toBe('README.md')
|
||||
// Reads require an addressed Session, even when the file path is absolute.
|
||||
@@ -66,6 +66,51 @@ describe('text type in the registry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
'../AGENTS.md',
|
||||
'../../AGENTS.md',
|
||||
'dir/../AGENTS.md',
|
||||
'dir/./AGENTS.md',
|
||||
'/workspace/../AGENTS.md',
|
||||
'C:/workspace/../AGENTS.md',
|
||||
'//host/share/workspace/../AGENTS.md',
|
||||
'',
|
||||
])('claims unresolved Session paths without rewriting their identity: %s', (path) => {
|
||||
const tabs = registry()
|
||||
const address = sessionFileAddress('s-1', path)
|
||||
expect(tabs.claim(address)).toEqual({ kind: TEXTPREVIEW_KIND, contentId: address, title: basenameOf(address) })
|
||||
expect(tabs.claim(address).contentId).toBe(address)
|
||||
expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's-1', path })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['./AGENTS.md', './AGENTS.md'],
|
||||
['%2e%2E/AGENTS.md', '../AGENTS.md'],
|
||||
['dir/%2E/AGENTS.md', 'dir/./AGENTS.md'],
|
||||
['%252e%252e/AGENTS.md', '%2e%2e/AGENTS.md'],
|
||||
['../a%20b%23c%3F.txt?ignored=1#section', '../a b#c?.txt'],
|
||||
])('claims an authored address while decoding its path once: %s', (encoded, path) => {
|
||||
const tabs = registry()
|
||||
const address = `dsh-resource://file/session/s%2F1/${encoded}`
|
||||
expect(tabs.claim(address).contentId).toBe(address)
|
||||
expect(parseFileAddress(address)).toEqual({ scope: 'session', sessionId: 's/1', path })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'dsh-resource://file/session',
|
||||
'dsh-resource://file/session//../a.md',
|
||||
'dsh-resource://file/session/s-1',
|
||||
'dsh-resource://file/session/%/../a.md',
|
||||
'dsh-resource://file/session/s-1/../%ZZ.md',
|
||||
'DSH-RESOURCE://file/session/s-1/../a.md',
|
||||
'dsh-resource://File/session/s-1/../a.md',
|
||||
])('keeps the Session parser veto after matching a file prefix: %s', (address) => {
|
||||
const tabs = registry()
|
||||
expect(tabs.candidates(address)).toEqual([])
|
||||
expect(() => tabs.claim(address)).toThrow('no registered tab type claims')
|
||||
expect(() => tabs.claim(address, TEXTPREVIEW_KIND)).toThrow(`tab type "${TEXTPREVIEW_KIND}" refuses`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'dsh-resource://file/shared/team/notes.md',
|
||||
'dsh-resource://file/absolute/home/me/notes.md',
|
||||
@@ -86,6 +131,38 @@ describe('text type in the registry', () => {
|
||||
expect(tabs.candidates('dsh-resource://file/session/s-1/w/logo.png').map(type => type.kind)).toEqual(['image', TEXTPREVIEW_KIND])
|
||||
})
|
||||
|
||||
it.each(['builtin', 'extension'] as const)('yields parent paths to the %s band and honors its veto', (priority) => {
|
||||
const tabs = registry()
|
||||
const address = sessionFileAddress('s-1', '../AGENTS.md')
|
||||
const release = tabs.register({
|
||||
id: 'test/markdown', kind: 'markdown', patterns: ['*.md'], priority, title: () => 'Markdown',
|
||||
})
|
||||
expect(tabs.candidates(address).map(type => type.kind)).toEqual(['markdown', TEXTPREVIEW_KIND])
|
||||
expect(tabs.claim(address)).toEqual({ kind: 'markdown', contentId: address, title: 'Markdown' })
|
||||
release()
|
||||
tabs.register({
|
||||
id: 'test/markdown', kind: 'markdown', patterns: ['*.md'], priority, canOpen: () => false, title: () => 'Markdown',
|
||||
})
|
||||
expect(tabs.claim(address).kind).toBe(TEXTPREVIEW_KIND)
|
||||
})
|
||||
|
||||
it('ranks the file declaration by its literal length within the fallback band', () => {
|
||||
const tabs = registry()
|
||||
tabs.register({
|
||||
id: 'test/shorter', kind: 'shorter', patterns: ['dsh-resource://file/**/*.*'], priority: 'fallback', title: () => 'Shorter',
|
||||
})
|
||||
const releaseLonger = tabs.register({
|
||||
id: 'test/longer', kind: 'longer', patterns: ['dsh-resource://file/session/**/*.md'], priority: 'fallback', title: () => 'Longer',
|
||||
})
|
||||
tabs.register({ ...textDefinition(), id: 'test/tied', kind: 'tied' })
|
||||
const address = sessionFileAddress('s-1', 'AGENTS.md')
|
||||
// Specificity uses the literal pattern length, not the size of its accepted address set.
|
||||
expect(tabs.candidates(address).map(type => type.kind)).toEqual(['longer', TEXTPREVIEW_KIND, 'tied', 'shorter'])
|
||||
expect(tabs.claim(address).kind).toBe('longer')
|
||||
releaseLonger()
|
||||
expect(tabs.claim(address).kind).toBe(TEXTPREVIEW_KIND)
|
||||
})
|
||||
|
||||
it('does not claim addresses of other schemes', () => {
|
||||
const tabs = registry()
|
||||
expect(() => tabs.claim('sidebar://guide')).toThrow('no registered tab type claims')
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 2e3c306b13d3bc25
|
||||
zh: 77127f01763b3976
|
||||
/deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views:
|
||||
en: 4d885fc693b497f4
|
||||
zh: 2f68d9ea92f2ebd6
|
||||
en: 3643ffbb8cf80e4c
|
||||
zh: b49654dc3b94b2be
|
||||
/deepseek-ai-dsh-client-ui-tool/understand-the-implementation:
|
||||
en: 00bc7b0c84edc603
|
||||
zh: 1cfcf5b435248a91
|
||||
|
||||
@@ -51,7 +51,7 @@ Every registered view receives the explicit `preparing`, `start`, and `result` p
|
||||
|
||||
File-tool rows prefer a complete `file_path`; other generic rows, including search and unregistered tools, prefer a non-empty `description` before their variant-specific argument fallback. Read `args` during rendering or view construction: a preparing reader grows in place, and only previously observed answer changes trigger subsequent block publication.
|
||||
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable. Background Bash calls expand to their original arguments and job acknowledgement. Their input scrolls horizontally without soft wrapping and remains selectable; only horizontal overflow adds a named keyboard focus target. Inspect is available when provided. The acknowledgement does not report the job's exit status. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable. Valid explicit background `bash`/`pwsh` calls use the terminal Start presentation; a successful launch acknowledgement retains the command and cwd in a static command-only card without output or execution status. Launch failures retain generic arguments and errors, while background `terminal_send` stays generic. Inspect preserves the original arguments and acknowledgement; later job output is not attached to this card ([decision](../../../.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.md)). Expanded terminal commands and background inputs wrap to the available width while preserving authored line breaks and selectable text; terminal output keeps its column-preserving layout. Only horizontal overflow adds a named keyboard focus target. Inspect is available when provided. The acknowledgement does not report the job's exit status. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
|
||||
Question rows pair answers with questions by id, using the tool result or the `userQuestions` projection for late replies. An answerable call has a button to reopen its panel. Once a late reply is in the agent Inbox, the row shows its submitted answers; “View answers” expands its read-only transcript while admission is pending. A settled call with answers opens the panel read-only. Closed calls without answers and invalid records offer no button. Cancelled and interrupted rows show their verdict without inventing answers. If no panel provider accepts the button action, it expands the row. Unsupported or ambiguous input falls back to flattened Tool text.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
|
||||
|
||||
文件工具行优先使用完整的 `file_path`;其他通用行(包括搜索和未注册的工具)优先使用非空 `description`,再回退到各自类别的参数摘要。应在渲染或构造视图时读取 `args`:准备态读器原地增长,后续仅在已观察答案变化时重新发布块。
|
||||
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开。后台 Bash 调用可展开查看原始参数与后台任务回执。输入内容可横向滚动,不自动折行,并可选中;仅横向溢出时添加具名的键盘聚焦目标。提供 Inspect 时也可使用。回执不表示后台任务的退出状态。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开。合法的主动后台 `bash`/`pwsh` 调用使用 terminal Start 展示;成功启动回执后,静态仅命令卡片保留 command 和 cwd,不显示输出或执行状态。启动失败保留通用参数与错误展示,后台 `terminal_send` 仍为 generic。Inspect 保留原始参数及回执,后续 job 输出不接回此卡片([决定](../../../.agents/notes/implemented/bug-fix/2026-10-10-background-shell-command-display.zh.md))。展开的终端命令与后台输入按可用宽度自动折行,保留原有换行和可选文本;终端输出保持按列对齐的排版。仅横向溢出时添加具名的键盘聚焦目标。提供 Inspect 时也可使用。回执不表示后台任务的退出状态。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
|
||||
问题行按 id 配对问题与答案;答案来自工具结果,迟到回答则来自 `userQuestions` 投影。可回答的调用提供按钮以重新打开面板。迟到回复进入 agent Inbox 后,问题行显示已提交的答案;等待准入期间,「查看回答」展开只读的行内记录。带有答案的已结算调用则以只读方式打开面板。没有答案的已关闭调用和无效记录不提供按钮。已取消和已中断的行显示裁决,不虚构答案。若面板提供方不接受按钮操作,点击会展开该行。不受支持或含糊的输入回退为压平的工具文本。
|
||||
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
--dsl-terminal-command-whitespace: pre-wrap;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface TerminalCardModel {
|
||||
* The locale-neutral props {@link TerminalBlock} draws. The render site adds
|
||||
* `command` after resolving {@link copy} through its locale seat.
|
||||
*/
|
||||
card: Pick<TerminalBlockProps, 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
card: Pick<TerminalBlockProps, 'cwd' | 'output' | 'exitCode' | 'signal' | 'running' | 'background'>
|
||||
/**
|
||||
* Verbatim Tool data or semantic `terminal_send` data. Product copy stays
|
||||
* unresolved until a render site supplies its locale seat.
|
||||
@@ -55,7 +55,7 @@ export interface TerminalCardModel {
|
||||
}
|
||||
|
||||
interface LocalizedTerminalCardModel {
|
||||
readonly card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
|
||||
readonly card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running' | 'background'>
|
||||
readonly description: string | undefined
|
||||
}
|
||||
|
||||
@@ -288,8 +288,9 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
|
||||
/**
|
||||
* Derive terminal props for supported shell and terminal-send calls, including
|
||||
* nested PTC dispatch calls. Standard shell results parse their final status
|
||||
* marker; persistent shell results, spill previews, background calls, errors,
|
||||
* and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic
|
||||
* marker. Successful background shell launches retain only their command after
|
||||
* settlement; persistent shell results, spill previews, background terminal sends,
|
||||
* errors, and malformed input use the generic path. {@link isSettledPersistentShellCall} lets that generic
|
||||
* persistent result remain expandable without inventing one process status.
|
||||
* @param block - running or settled Tool block.
|
||||
* @param sessionCwd - original workspace root used only for old results without recorded cwd.
|
||||
@@ -302,7 +303,7 @@ export function terminalCardModel(
|
||||
const parsed = parsedToolCall(block)
|
||||
if (parsed === null) return null
|
||||
const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)
|
||||
if (call === null || call.background) return null
|
||||
if (call === null || (call.kind === 'terminal-send' && call.background)) return null
|
||||
|
||||
const copy: TerminalCardModel['copy'] = call.kind === 'shell'
|
||||
? { kind: 'shell', command: call.command, description: call.description }
|
||||
@@ -322,10 +323,14 @@ export function terminalCardModel(
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
running: true,
|
||||
...(call.kind === 'shell' && call.background ? { background: call.background } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (block.isError || (call.kind === 'shell' && call.persistent) || isSpilledShellCall(block)) return null
|
||||
if (call.kind === 'shell' && call.background) {
|
||||
return { copy, card: { cwd, running: false, background: call.background } }
|
||||
}
|
||||
const output = singleResultText(block)
|
||||
if (output === undefined) return null
|
||||
const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
--dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
|
||||
--dsl-terminal-line-height: 18px;
|
||||
--dsl-terminal-output-max-height: 224px;
|
||||
--dsl-terminal-command-whitespace: pre-wrap;
|
||||
margin: 4px 0 4px 4px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
@@ -78,6 +79,7 @@
|
||||
}
|
||||
|
||||
.commandInput {
|
||||
--dsl-command-whitespace: pre-wrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,8 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe
|
||||
const status = stateStatus(state, t)
|
||||
const { expanded, toggle: toggleExpand } = useDisclosure()
|
||||
const background = isBackgroundShellCall(block)
|
||||
// Background launches expose their acknowledgement without assigning the
|
||||
// job an exit status. Failures, persistent results, and spill previews also
|
||||
// keep the generic input/output presentation.
|
||||
// Failed or interrupted background launches retain raw input/output, as do
|
||||
// other execution failures, persistent results, and spill previews.
|
||||
const genericBody = terminal === null
|
||||
&& (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block) || background)
|
||||
&& (model.bodyRaw !== null || model.output !== null)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
@@ -264,8 +264,35 @@ describe('terminalCardModel', () => {
|
||||
}))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for background, errors, malformed args, unsupported tools, and non-text results', () => {
|
||||
expect(terminalCardModel(running({ argsRaw: shellArgs({ run_in_background: true }) }))).toBeNull()
|
||||
it.each(['bash', 'pwsh'])('derives %s background launch commands without treating acknowledgement as output', (name) => {
|
||||
const argsRaw = shellArgs({ run_in_background: true, workdir: '/requested' })
|
||||
for (const parentCallId of [undefined, 'parent']) {
|
||||
const parent = parentCallId === undefined ? {} : { parentCallId }
|
||||
expect(terminalCardModel(running({ name, argsRaw, ...parent }))).toEqual({
|
||||
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
|
||||
card: { cwd: '/requested', running: true, background: true, output: undefined, exitCode: undefined, signal: undefined },
|
||||
})
|
||||
for (const content of [[], [{ type: 'text' as const, text: 'started background job bash-1\n[exit code: 7]' }]]) {
|
||||
const block = settled({ call: { name, argsRaw }, ...parent, meta: { cwd: '/recorded' }, content })
|
||||
const model = terminalCardModel(block)!
|
||||
expect(model).toEqual({
|
||||
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
|
||||
card: { cwd: '/recorded', running: false, background: true },
|
||||
})
|
||||
expect(terminalFailed(model)).toBe(false)
|
||||
expect(localizeTerminalCardModel(model, t).card).toEqual({
|
||||
command: 'ls -la', cwd: '/recorded', running: false, background: true,
|
||||
})
|
||||
expect(terminalCardModel({ ...block, isError: true })).toBeNull()
|
||||
expect(block.content).toBe(content)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for background terminal sends, errors, malformed args, unsupported tools, and non-text results', () => {
|
||||
expect(terminalCardModel(running({ name: 'terminal_send', argsRaw: JSON.stringify({
|
||||
sessionId: 'pty-3', text: 'make', run_in_background: true,
|
||||
}) }))).toBeNull()
|
||||
expect(terminalCardModel(settled({ isError: true }))).toBeNull()
|
||||
expect(terminalCardModel(running({ argsRaw: '{' }))).toBeNull()
|
||||
expect(terminalCardModel(running({ name: 'read' }))).toBeNull()
|
||||
@@ -405,12 +432,13 @@ describe('chat row terminal body', () => {
|
||||
expect(view.getByText(command)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a non-terminal call keeps the args-JSON text body', () => {
|
||||
it('a background terminal_send keeps the args-JSON text body', () => {
|
||||
const view = render(<GenericToolCard {...ownerProps(settled({
|
||||
call: { name: 'bash', argsRaw: shellArgs({ run_in_background: true }) },
|
||||
}))} />)
|
||||
call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) },
|
||||
}))} toolName="terminal_send" />)
|
||||
toggleRow(view)
|
||||
expect(view.getByText(/"command"/)).toBeTruthy()
|
||||
expect(view.getByText(/"sessionId"/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
})
|
||||
|
||||
it('malformed empty args use the generic output body', () => {
|
||||
@@ -527,45 +555,112 @@ describe('BashRow terminal card', () => {
|
||||
expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent).toBe(output)
|
||||
})
|
||||
|
||||
it.each([undefined, 'dispatch-parent'])('expands a background launch and its acknowledgement without reporting a job exit (parent: %s)', (parentCallId) => {
|
||||
const command = 'pnpm --filter @deepseek-ai/dsh-client-ui-tool run build --watch --verbose'
|
||||
const args = { command, description: 'Wait', run_in_background: true }
|
||||
const argsRaw = JSON.stringify(args)
|
||||
it.each([
|
||||
['bash', undefined], ['bash', 'dispatch-parent'], ['pwsh', undefined], ['pwsh', 'dispatch-parent'],
|
||||
] as const)('keeps the %s background command open after launch without reporting job status (parent: %s)', (name, parentCallId) => {
|
||||
const command = ` node worker.mjs --trace=${'background-worker-'.repeat(24)}\n\tprintf "done"\n`
|
||||
const argsRaw = shellArgs({ command, description: 'Wait', workdir: '/workspace/app', run_in_background: true })
|
||||
const inspect = vi.fn()
|
||||
const parent = parentCallId === undefined ? {} : { parentCallId }
|
||||
const view = render(<BashRow {...rowProps(running({ argsRaw, ...parent }))} inspect={inspect} />)
|
||||
const row = view.container.querySelector('[data-sample="bash"]')!
|
||||
const renderRow = (block: StartedToolCall | ToolResultNode) => {
|
||||
const props = { ...rowProps(block), toolName: name, inspect }
|
||||
return name === 'bash' ? <BashRow {...props} /> : <GenericToolCard {...props} />
|
||||
}
|
||||
const view = render(renderRow(running({ name, argsRaw, ...parent })))
|
||||
const row = view.getByRole('button')
|
||||
expect(view.getByText('Wait')).toBeTruthy()
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-command-text]')?.textContent)
|
||||
.toBe(JSON.stringify(args, null, 2))
|
||||
expect(view.container.querySelector('[data-command-text]')?.hasAttribute('tabindex')).toBe(false)
|
||||
expect(view.queryByText('输出')).toBeNull()
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
if (name === 'bash') fireEvent.keyDown(row, { key: 'Enter' })
|
||||
else fireEvent.click(row)
|
||||
const terminal = view.container.querySelector('[data-terminal]')
|
||||
const firstCommand = view.container.querySelector('[data-command-text]')
|
||||
expect(terminal).not.toBeNull()
|
||||
expect(runStateOf(view.container)).toBe('ongoing')
|
||||
expect([...view.container.querySelectorAll('[data-command-text]')].map(line => line.textContent))
|
||||
.toEqual(command.slice(0, -1).split('\n'))
|
||||
expect(view.getByText('app')).toBeTruthy()
|
||||
expect(view.queryByText('无输出')).toBeNull()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
|
||||
const acknowledgement = 'started background job job-1'
|
||||
view.rerender(<BashRow {...rowProps(settled({
|
||||
call: { name: 'bash', argsRaw }, ...parent,
|
||||
content: [{ type: 'text', text: acknowledgement }],
|
||||
}))} inspect={inspect} />)
|
||||
|
||||
const result = settled({ call: { name, argsRaw }, ...parent, meta: { cwd: '/workspace/app' },
|
||||
content: [{ type: 'text', text: acknowledgement }] })
|
||||
view.rerender(renderRow(result))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-command-text]')?.textContent)
|
||||
.toBe(JSON.stringify(args, null, 2))
|
||||
expect(view.getByText(acknowledgement)).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-terminal], [data-state="done"]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-terminal]')).toBe(terminal)
|
||||
expect(view.container.querySelector('[data-command-text]')).toBe(firstCommand)
|
||||
expect(view.container.querySelector('[data-terminal] [data-state], [data-running]')).toBeNull()
|
||||
expect(view.queryByText('运行中')).toBeNull()
|
||||
expect(view.queryByText('已完成')).toBeNull()
|
||||
expect(view.queryByText('无输出')).toBeNull()
|
||||
expect(view.queryByText(acknowledgement)).toBeNull()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(result.call?.argsRaw).toBe(argsRaw)
|
||||
expect(result.content).toEqual([{ type: 'text', text: acknowledgement }])
|
||||
fireEvent.click(view.getByRole('button', { name: t('row.inspect') }))
|
||||
expect(inspect).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
if (name === 'bash') fireEvent.keyDown(row, { key: ' ' })
|
||||
else fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-command-text]')?.textContent).toBe(command.split('\n')[0])
|
||||
expect(view.queryByText(acknowledgement)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bash', false], ['bash', true], ['pwsh', false], ['pwsh', true],
|
||||
] as const)('keeps %s launch failures on the original generic body (interrupted: %s)', (name, interrupted) => {
|
||||
const argsRaw = shellArgs({ run_in_background: true })
|
||||
const renderRow = (block: StartedToolCall | ToolResultNode) => {
|
||||
const props = { ...rowProps(block), toolName: name }
|
||||
return name === 'bash' ? <BashRow {...props} /> : <GenericToolCard {...props} />
|
||||
}
|
||||
const view = render(renderRow(running({ name, argsRaw })))
|
||||
const row = view.getByRole('button')
|
||||
fireEvent.click(row)
|
||||
view.rerender(renderRow(settled({ call: { name, argsRaw }, isError: true,
|
||||
...interrupted ? { error: { name: 'Interrupted', code: 'interrupted' } } : {},
|
||||
content: [{ type: 'text', text: 'Launch did not finish' }] })))
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.container.querySelector('[data-terminal]')).toBeNull()
|
||||
expect(view.getByText(/"run_in_background": true/)).toBeTruthy()
|
||||
expect(view.getAllByText('Launch did not finish').length).toBeGreaterThan(0)
|
||||
expect(view.container.querySelector(`[data-state="${interrupted ? 'stopped' : 'error'}"]`)).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(['BashRow', 'GenericToolCard'] as const)('%s preserves authored command lines and copies only raw output', async (renderer) => {
|
||||
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
const writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
|
||||
const lines = [` node worker.mjs --trace=${'command-token-'.repeat(24)} `, '', '\tprintf "done"']
|
||||
const command = `${lines.join('\n')}\n`
|
||||
const output = ' first\tcolumn\n\nlast \n'
|
||||
const props = rowProps(settled({
|
||||
call: { name: 'bash', argsRaw: shellArgs({ command }) },
|
||||
content: [{ type: 'text', text: `${output}\n[exit code: 0]` }],
|
||||
}))
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const view = render(renderer === 'BashRow' ? <BashRow {...props} /> : <GenericToolCard {...props} />)
|
||||
fireEvent.click(view.container.querySelector('[data-expandable]')!)
|
||||
expect([...view.container.querySelectorAll('[data-command-text]')].map(line => line.textContent)).toEqual(lines)
|
||||
expect(view.getByText(' first\tcolumn', RAW)).toBeTruthy()
|
||||
expect(view.getByText('last ', RAW)).toBeTruthy()
|
||||
await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) })
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
expect(writeText).toHaveBeenCalledWith(output)
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync() })
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
||||
else Object.defineProperty(navigator, 'clipboard', clipboard)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps malformed shell arguments collapsed even when background execution is requested', () => {
|
||||
const view = render(<BashRow {...rowProps(settled({
|
||||
call: { name: 'bash', argsRaw: JSON.stringify({ description: 'Wait', run_in_background: true }) },
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
|
||||
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
|
||||
* exist but not whether a narrow row still fits on one line; these read the
|
||||
* declarations the layout depends on.
|
||||
*/
|
||||
/** Consumer-local command wrapping and collapsed summary declarations; jsdom has no layout. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -12,15 +7,31 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/tool/components/To
|
||||
/** Declarations only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
function declarations(selector: string, sheet = declarationText): string[] {
|
||||
// Anchored at a rule boundary: an unanchored match would silently read a
|
||||
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
|
||||
// if one ever lands above the base rule.
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
|
||||
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(sheet)
|
||||
if (rule === null) throw new Error(`Stylesheet has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('expanded command wrapping', () => {
|
||||
it.each([
|
||||
['../src/client/tool/toolviews/bash-sample.module.css', '.terminal', '--dsl-terminal-command-whitespace'],
|
||||
['../src/client/tool/toolviews/bash-sample.module.css', '.commandInput', '--dsl-command-whitespace'],
|
||||
['../src/client/tool/components/ToolRow.module.css', '.terminalBody', '--dsl-terminal-command-whitespace'],
|
||||
] as const)('%s opts %s into command wrapping only', (path, selector, property) => {
|
||||
const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
expect(declarations(selector, sheet)).toContain(`${property}: pre-wrap`)
|
||||
expect(sheet).not.toContain('--dsl-terminal-line-whitespace')
|
||||
expect(declarations('.summary', sheet)).toEqual(expect.arrayContaining([
|
||||
'white-space: nowrap', 'overflow: hidden', 'text-overflow: ellipsis',
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolRow.module.css summary line', () => {
|
||||
it.each([
|
||||
['ToolRow', '../src/client/tool/components/ToolRow.module.css'],
|
||||
|
||||
@@ -42,7 +42,7 @@ function restore(bytes: Uint8Array | undefined) {
|
||||
}
|
||||
|
||||
describe('Node preparation of Preview Session data', () => {
|
||||
it('packs strictly migrated current successors beside every unchanged example file', () => {
|
||||
it('packs current Sessions and migrated children beside every unchanged example file', () => {
|
||||
const fixture = previewFixtures(repoRoot)[0]!
|
||||
const original = packVfsOverlay(fixture.trees)
|
||||
const projected = packPreviewFixture(fixture.trees)
|
||||
@@ -50,15 +50,13 @@ describe('Node preparation of Preview Session data', () => {
|
||||
expect(currentFiles).toHaveLength(3)
|
||||
for (const [path, bytes] of Object.entries(original.files)) expect(projected.files[path], path).toEqual(bytes)
|
||||
for (const path of currentFiles) {
|
||||
const sourcePath = path.replace(/\/session\.v\d+\.jsonl$/u, '/session.v3.jsonl')
|
||||
const historical = restore(original.files[sourcePath])
|
||||
const current = restore(projected.files[path])
|
||||
const appended = path.includes('/preview-showcase/') ? [
|
||||
{ version: 0, childId: 'preview-architecture-review', childCreatedAt: 1787472100000, mode: 'one-shot', label: 'Review preview architecture' },
|
||||
{ version: 0, childId: 'preview-follow-up-builder', childCreatedAt: 1787472200000, mode: 'continuable', label: 'Continue preview verification' },
|
||||
].map((data, index) => ({ type: 'subagent/catalog', seq: historical.events.length + index,
|
||||
time: historical.events.at(-1)!.time, data })) : []
|
||||
expect(current).toEqual({ ...historical, events: [...historical.events, ...appended] })
|
||||
if (original.files[path] !== undefined) {
|
||||
expect(projected.files[path]).toEqual(original.files[path])
|
||||
} else {
|
||||
const sourcePath = path.replace(/\/session\.v\d+\.jsonl$/u, '/session.v3.jsonl')
|
||||
expect(current).toEqual(restore(original.files[sourcePath]))
|
||||
}
|
||||
expect(JSON.parse(text(projected.files[path]).split('\n')[0]!)).toMatchObject({ version: currentVersion })
|
||||
}
|
||||
expect(packPreviewFixture(fixture.trees).image).toEqual(projected.image)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 4e53164159b5ce41
|
||||
zh: 89582dc0294d0776
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/use-this-package:
|
||||
en: 6dd3d8495bc6ee61
|
||||
zh: 71f7da310233a37b
|
||||
en: aed3c07b75b29e34
|
||||
zh: b609ae0f5796aa34
|
||||
/deepseek-ai-dsh-experimental-webworker-runtime/model-experience:
|
||||
en: 61fce9b4c7dc96e6
|
||||
zh: dc2d931063a38327
|
||||
|
||||
@@ -32,7 +32,7 @@ Three build artifacts and one source-owned process layer:
|
||||
- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; `xdg-open` sends one VFS text file to the page's read-only viewer, so desktop-opener actions such as **Open configuration file** show the file in the page; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process.
|
||||
- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The worker's unsolicited `view-text` frame opens the page's modal text viewer. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream<Uint8Array>` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. A Remote stream's uplink crosses as `stream-uplink-item` / `stream-uplink-end` frames into an unbounded `TunnelUplink` that drops items arriving after `end`: the page and its worker are one trusted same-origin boundary, so the Host inbox cap does not apply.
|
||||
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The showcase mixes empty, whitespace-only, and non-empty reasoning with a long multiline command and a parent-path file link; these are authored fixtures, not live model output. The [packer](../webworker-packer/README.md) prepares current-generation successors from committed historical logs in Node; those source logs remain byte-identical in the overlay. The fixture generator owns the acceptance test's current projection cache. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture.
|
||||
|
||||
The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension, error code, or message, and an unexpectedly successful exempt import, remain errors.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ kind: "package-library"
|
||||
- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;`xdg-open` 把一个 VFS 文本文件交给页面的只读查看器,因此 **打开配置文件** 等桌面打开操作会在页面内显示该文件;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。
|
||||
- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。worker 主动发出的 `view-text` 帧会打开页面的模态文本查看器。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream<Uint8Array>` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。Remote 流的上行以 `stream-uplink-item` / `stream-uplink-end` 帧进入没有上限的 `TunnelUplink`,`end` 之后到达的项被丢弃:页面与其 worker 是同一个可信的同源边界,Host 的 inbox 上限在此不适用。
|
||||
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。showcase 混排空字符串、纯空白与非空推理块,并包含多行长命令和父路径文件链接;这些是人工构造的 fixture,不是真实模型输出。[打包器](../webworker-packer/README.zh.md)在 Node 中根据已提交的历史日志准备当前代后继文件;这些源日志在 overlay 中逐字节保持不变。fixture 生成器负责验收测试的当前投影缓存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。
|
||||
|
||||
[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名、错误码或消息,以及意外成功的豁免导入,仍然报错。
|
||||
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
{"type":"session","version":4,"id":"preview-showcase","createdAt":1787472000000,"cwd":"/dsh/workspace","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"}
|
||||
{"type":"turn/start","data":{"turn":1},"seq":0,"time":1787472000000}
|
||||
{"type":"user/message","data":{"id":"preview-user-01","role":"user","content":[{"type":"text","text":"History checkpoint 01: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":1,"time":1787472000001}
|
||||
{"type":"session/title","data":{"title":"WebWorker Preview Showcase","messageSeqs":[],"source":{"kind":"user"}},"seq":2,"time":1787472000002}
|
||||
{"type":"step/start","data":{"turn":1,"step":1},"seq":3,"time":1787472000003}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"id":"preview-assistant-01","role":"assistant","content":[{"type":"text","text":"Checkpoint 01 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 01 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 01 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":4,"time":1787472000004}
|
||||
{"type":"step/end","data":{"turn":1,"step":1},"seq":5,"time":1787472000005}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}},"seq":6,"time":1787472000006}
|
||||
{"type":"turn/start","data":{"turn":2},"seq":7,"time":1787472000007}
|
||||
{"type":"user/message","data":{"id":"preview-user-02","role":"user","content":[{"type":"text","text":"History checkpoint 02: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":8,"time":1787472000008}
|
||||
{"type":"step/start","data":{"turn":2,"step":1},"seq":9,"time":1787472000009}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"id":"preview-assistant-02","role":"assistant","content":[{"type":"text","text":"Checkpoint 02 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 02 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 02 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":10,"time":1787472000010}
|
||||
{"type":"step/end","data":{"turn":2,"step":1},"seq":11,"time":1787472000011}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}},"seq":12,"time":1787472000012}
|
||||
{"type":"turn/start","data":{"turn":3},"seq":13,"time":1787472000013}
|
||||
{"type":"user/message","data":{"id":"preview-user-03","role":"user","content":[{"type":"text","text":"History checkpoint 03: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":14,"time":1787472000014}
|
||||
{"type":"step/start","data":{"turn":3,"step":1},"seq":15,"time":1787472000015}
|
||||
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"id":"preview-assistant-03","role":"assistant","content":[{"type":"text","text":"Checkpoint 03 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 03 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 03 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":16,"time":1787472000016}
|
||||
{"type":"step/end","data":{"turn":3,"step":1},"seq":17,"time":1787472000017}
|
||||
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}},"seq":18,"time":1787472000018}
|
||||
{"type":"turn/start","data":{"turn":4},"seq":19,"time":1787472000019}
|
||||
{"type":"user/message","data":{"id":"preview-user-04","role":"user","content":[{"type":"text","text":"History checkpoint 04: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":20,"time":1787472000020}
|
||||
{"type":"step/start","data":{"turn":4,"step":1},"seq":21,"time":1787472000021}
|
||||
{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"id":"preview-assistant-04","role":"assistant","content":[{"type":"text","text":"Checkpoint 04 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 04 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 04 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":22,"time":1787472000022}
|
||||
{"type":"step/end","data":{"turn":4,"step":1},"seq":23,"time":1787472000023}
|
||||
{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}},"seq":24,"time":1787472000024}
|
||||
{"type":"turn/start","data":{"turn":5},"seq":25,"time":1787472000025}
|
||||
{"type":"user/message","data":{"id":"preview-user-05","role":"user","content":[{"type":"text","text":"History checkpoint 05: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":26,"time":1787472000026}
|
||||
{"type":"step/start","data":{"turn":5,"step":1},"seq":27,"time":1787472000027}
|
||||
{"type":"assistant/message","data":{"turn":5,"step":1,"message":{"id":"preview-assistant-05","role":"assistant","content":[{"type":"text","text":"Checkpoint 05 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 05 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 05 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":28,"time":1787472000028}
|
||||
{"type":"step/end","data":{"turn":5,"step":1},"seq":29,"time":1787472000029}
|
||||
{"type":"turn/end","data":{"turn":5,"reason":{"kind":"completed"}},"seq":30,"time":1787472000030}
|
||||
{"type":"turn/start","data":{"turn":6},"seq":31,"time":1787472000031}
|
||||
{"type":"user/message","data":{"id":"preview-user-06","role":"user","content":[{"type":"text","text":"History checkpoint 06: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":32,"time":1787472000032}
|
||||
{"type":"step/start","data":{"turn":6,"step":1},"seq":33,"time":1787472000033}
|
||||
{"type":"assistant/message","data":{"turn":6,"step":1,"message":{"id":"preview-assistant-06","role":"assistant","content":[{"type":"text","text":"Checkpoint 06 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 06 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 06 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":34,"time":1787472000034}
|
||||
{"type":"step/end","data":{"turn":6,"step":1},"seq":35,"time":1787472000035}
|
||||
{"type":"turn/end","data":{"turn":6,"reason":{"kind":"completed"}},"seq":36,"time":1787472000036}
|
||||
{"type":"turn/start","data":{"turn":7},"seq":37,"time":1787472000037}
|
||||
{"type":"user/message","data":{"id":"preview-user-07","role":"user","content":[{"type":"text","text":"History checkpoint 07: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":38,"time":1787472000038}
|
||||
{"type":"step/start","data":{"turn":7,"step":1},"seq":39,"time":1787472000039}
|
||||
{"type":"assistant/message","data":{"turn":7,"step":1,"message":{"id":"preview-assistant-07","role":"assistant","content":[{"type":"text","text":"Checkpoint 07 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 07 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 07 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":40,"time":1787472000040}
|
||||
{"type":"step/end","data":{"turn":7,"step":1},"seq":41,"time":1787472000041}
|
||||
{"type":"turn/end","data":{"turn":7,"reason":{"kind":"completed"}},"seq":42,"time":1787472000042}
|
||||
{"type":"turn/start","data":{"turn":8},"seq":43,"time":1787472000043}
|
||||
{"type":"user/message","data":{"id":"preview-user-08","role":"user","content":[{"type":"text","text":"History checkpoint 08: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":44,"time":1787472000044}
|
||||
{"type":"step/start","data":{"turn":8,"step":1},"seq":45,"time":1787472000045}
|
||||
{"type":"assistant/message","data":{"turn":8,"step":1,"message":{"id":"preview-assistant-08","role":"assistant","content":[{"type":"text","text":"Checkpoint 08 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 08 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 08 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":46,"time":1787472000046}
|
||||
{"type":"step/end","data":{"turn":8,"step":1},"seq":47,"time":1787472000047}
|
||||
{"type":"turn/end","data":{"turn":8,"reason":{"kind":"completed"}},"seq":48,"time":1787472000048}
|
||||
{"type":"turn/start","data":{"turn":9},"seq":49,"time":1787472000049}
|
||||
{"type":"user/message","data":{"id":"preview-user-09","role":"user","content":[{"type":"text","text":"History checkpoint 09: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":50,"time":1787472000050}
|
||||
{"type":"step/start","data":{"turn":9,"step":1},"seq":51,"time":1787472000051}
|
||||
{"type":"assistant/message","data":{"turn":9,"step":1,"message":{"id":"preview-assistant-09","role":"assistant","content":[{"type":"text","text":"Checkpoint 09 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 09 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 09 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":52,"time":1787472000052}
|
||||
{"type":"step/end","data":{"turn":9,"step":1},"seq":53,"time":1787472000053}
|
||||
{"type":"turn/end","data":{"turn":9,"reason":{"kind":"completed"}},"seq":54,"time":1787472000054}
|
||||
{"type":"turn/start","data":{"turn":10},"seq":55,"time":1787472000055}
|
||||
{"type":"user/message","data":{"id":"preview-user-10","role":"user","content":[{"type":"text","text":"History checkpoint 10: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":56,"time":1787472000056}
|
||||
{"type":"step/start","data":{"turn":10,"step":1},"seq":57,"time":1787472000057}
|
||||
{"type":"assistant/message","data":{"turn":10,"step":1,"message":{"id":"preview-assistant-10","role":"assistant","content":[{"type":"text","text":"Checkpoint 10 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 10 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 10 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":58,"time":1787472000058}
|
||||
{"type":"step/end","data":{"turn":10,"step":1},"seq":59,"time":1787472000059}
|
||||
{"type":"turn/end","data":{"turn":10,"reason":{"kind":"completed"}},"seq":60,"time":1787472000060}
|
||||
{"type":"turn/start","data":{"turn":11},"seq":61,"time":1787472000061}
|
||||
{"type":"user/message","data":{"id":"preview-user-11","role":"user","content":[{"type":"text","text":"History checkpoint 11: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":62,"time":1787472000062}
|
||||
{"type":"step/start","data":{"turn":11,"step":1},"seq":63,"time":1787472000063}
|
||||
{"type":"assistant/message","data":{"turn":11,"step":1,"message":{"id":"preview-assistant-11","role":"assistant","content":[{"type":"text","text":"Checkpoint 11 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 11 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 11 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":64,"time":1787472000064}
|
||||
{"type":"step/end","data":{"turn":11,"step":1},"seq":65,"time":1787472000065}
|
||||
{"type":"turn/end","data":{"turn":11,"reason":{"kind":"completed"}},"seq":66,"time":1787472000066}
|
||||
{"type":"turn/start","data":{"turn":12},"seq":67,"time":1787472000067}
|
||||
{"type":"user/message","data":{"id":"preview-user-12","role":"user","content":[{"type":"text","text":"History checkpoint 12: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":68,"time":1787472000068}
|
||||
{"type":"step/start","data":{"turn":12,"step":1},"seq":69,"time":1787472000069}
|
||||
{"type":"assistant/message","data":{"turn":12,"step":1,"message":{"id":"preview-assistant-12","role":"assistant","content":[{"type":"text","text":"Checkpoint 12 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 12 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 12 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":70,"time":1787472000070}
|
||||
{"type":"step/end","data":{"turn":12,"step":1},"seq":71,"time":1787472000071}
|
||||
{"type":"turn/end","data":{"turn":12,"reason":{"kind":"completed"}},"seq":72,"time":1787472000072}
|
||||
{"type":"turn/start","data":{"turn":13},"seq":73,"time":1787472000073}
|
||||
{"type":"user/message","data":{"id":"preview-user-13","role":"user","content":[{"type":"text","text":"History checkpoint 13: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":74,"time":1787472000074}
|
||||
{"type":"step/start","data":{"turn":13,"step":1},"seq":75,"time":1787472000075}
|
||||
{"type":"assistant/message","data":{"turn":13,"step":1,"message":{"id":"preview-assistant-13","role":"assistant","content":[{"type":"text","text":"Checkpoint 13 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 13 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 13 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":76,"time":1787472000076}
|
||||
{"type":"step/end","data":{"turn":13,"step":1},"seq":77,"time":1787472000077}
|
||||
{"type":"turn/end","data":{"turn":13,"reason":{"kind":"completed"}},"seq":78,"time":1787472000078}
|
||||
{"type":"turn/start","data":{"turn":14},"seq":79,"time":1787472000079}
|
||||
{"type":"user/message","data":{"id":"preview-user-14","role":"user","content":[{"type":"text","text":"History checkpoint 14: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":80,"time":1787472000080}
|
||||
{"type":"step/start","data":{"turn":14,"step":1},"seq":81,"time":1787472000081}
|
||||
{"type":"assistant/message","data":{"turn":14,"step":1,"message":{"id":"preview-assistant-14","role":"assistant","content":[{"type":"text","text":"Checkpoint 14 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 14 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 14 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":82,"time":1787472000082}
|
||||
{"type":"step/end","data":{"turn":14,"step":1},"seq":83,"time":1787472000083}
|
||||
{"type":"turn/end","data":{"turn":14,"reason":{"kind":"completed"}},"seq":84,"time":1787472000084}
|
||||
{"type":"turn/start","data":{"turn":15},"seq":85,"time":1787472000085}
|
||||
{"type":"user/message","data":{"id":"preview-user-15","role":"user","content":[{"type":"text","text":"History checkpoint 15: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":86,"time":1787472000086}
|
||||
{"type":"step/start","data":{"turn":15,"step":1},"seq":87,"time":1787472000087}
|
||||
{"type":"assistant/message","data":{"turn":15,"step":1,"message":{"id":"preview-assistant-15","role":"assistant","content":[{"type":"text","text":"Checkpoint 15 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 15 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 15 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":88,"time":1787472000088}
|
||||
{"type":"step/end","data":{"turn":15,"step":1},"seq":89,"time":1787472000089}
|
||||
{"type":"turn/end","data":{"turn":15,"reason":{"kind":"completed"}},"seq":90,"time":1787472000090}
|
||||
{"type":"turn/start","data":{"turn":16},"seq":91,"time":1787472000091}
|
||||
{"type":"user/message","data":{"id":"preview-user-16","role":"user","content":[{"type":"text","text":"History checkpoint 16: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":92,"time":1787472000092}
|
||||
{"type":"step/start","data":{"turn":16,"step":1},"seq":93,"time":1787472000093}
|
||||
{"type":"assistant/message","data":{"turn":16,"step":1,"message":{"id":"preview-assistant-16","role":"assistant","content":[{"type":"text","text":"Checkpoint 16 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 16 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 16 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":94,"time":1787472000094}
|
||||
{"type":"step/end","data":{"turn":16,"step":1},"seq":95,"time":1787472000095}
|
||||
{"type":"turn/end","data":{"turn":16,"reason":{"kind":"completed"}},"seq":96,"time":1787472000096}
|
||||
{"type":"turn/start","data":{"turn":17},"seq":97,"time":1787472000097}
|
||||
{"type":"user/message","data":{"id":"preview-user-17","role":"user","content":[{"type":"text","text":"History checkpoint 17: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":98,"time":1787472000098}
|
||||
{"type":"step/start","data":{"turn":17,"step":1},"seq":99,"time":1787472000099}
|
||||
{"type":"assistant/message","data":{"turn":17,"step":1,"message":{"id":"preview-assistant-17","role":"assistant","content":[{"type":"text","text":"Checkpoint 17 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 17 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 17 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":100,"time":1787472000100}
|
||||
{"type":"step/end","data":{"turn":17,"step":1},"seq":101,"time":1787472000101}
|
||||
{"type":"turn/end","data":{"turn":17,"reason":{"kind":"completed"}},"seq":102,"time":1787472000102}
|
||||
{"type":"turn/start","data":{"turn":18},"seq":103,"time":1787472000103}
|
||||
{"type":"user/message","data":{"id":"preview-user-18","role":"user","content":[{"type":"text","text":"History checkpoint 18: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":104,"time":1787472000104}
|
||||
{"type":"step/start","data":{"turn":18,"step":1},"seq":105,"time":1787472000105}
|
||||
{"type":"assistant/message","data":{"turn":18,"step":1,"message":{"id":"preview-assistant-18","role":"assistant","content":[{"type":"text","text":"Checkpoint 18 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 18 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 18 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":106,"time":1787472000106}
|
||||
{"type":"step/end","data":{"turn":18,"step":1},"seq":107,"time":1787472000107}
|
||||
{"type":"turn/end","data":{"turn":18,"reason":{"kind":"completed"}},"seq":108,"time":1787472000108}
|
||||
{"type":"turn/start","data":{"turn":19},"seq":109,"time":1787472000109}
|
||||
{"type":"user/message","data":{"id":"preview-user-19","role":"user","content":[{"type":"text","text":"History checkpoint 19: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":110,"time":1787472000110}
|
||||
{"type":"step/start","data":{"turn":19,"step":1},"seq":111,"time":1787472000111}
|
||||
{"type":"assistant/message","data":{"turn":19,"step":1,"message":{"id":"preview-assistant-19","role":"assistant","content":[{"type":"text","text":"Checkpoint 19 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 19 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 19 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":112,"time":1787472000112}
|
||||
{"type":"step/end","data":{"turn":19,"step":1},"seq":113,"time":1787472000113}
|
||||
{"type":"turn/end","data":{"turn":19,"reason":{"kind":"completed"}},"seq":114,"time":1787472000114}
|
||||
{"type":"turn/start","data":{"turn":20},"seq":115,"time":1787472000115}
|
||||
{"type":"user/message","data":{"id":"preview-user-20","role":"user","content":[{"type":"text","text":"History checkpoint 20: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":116,"time":1787472000116}
|
||||
{"type":"step/start","data":{"turn":20,"step":1},"seq":117,"time":1787472000117}
|
||||
{"type":"assistant/message","data":{"turn":20,"step":1,"message":{"id":"preview-assistant-20","role":"assistant","content":[{"type":"text","text":"Checkpoint 20 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 20 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 20 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":118,"time":1787472000118}
|
||||
{"type":"step/end","data":{"turn":20,"step":1},"seq":119,"time":1787472000119}
|
||||
{"type":"turn/end","data":{"turn":20,"reason":{"kind":"completed"}},"seq":120,"time":1787472000120}
|
||||
{"type":"turn/start","data":{"turn":21},"seq":121,"time":1787472000121}
|
||||
{"type":"user/message","data":{"id":"preview-user-21","role":"user","content":[{"type":"text","text":"History checkpoint 21: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":122,"time":1787472000122}
|
||||
{"type":"step/start","data":{"turn":21,"step":1},"seq":123,"time":1787472000123}
|
||||
{"type":"assistant/message","data":{"turn":21,"step":1,"message":{"id":"preview-assistant-21","role":"assistant","content":[{"type":"text","text":"Checkpoint 21 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 21 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 21 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":124,"time":1787472000124}
|
||||
{"type":"step/end","data":{"turn":21,"step":1},"seq":125,"time":1787472000125}
|
||||
{"type":"turn/end","data":{"turn":21,"reason":{"kind":"completed"}},"seq":126,"time":1787472000126}
|
||||
{"type":"turn/start","data":{"turn":22},"seq":127,"time":1787472000127}
|
||||
{"type":"user/message","data":{"id":"preview-user-22","role":"user","content":[{"type":"text","text":"History checkpoint 22: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":128,"time":1787472000128}
|
||||
{"type":"step/start","data":{"turn":22,"step":1},"seq":129,"time":1787472000129}
|
||||
{"type":"assistant/message","data":{"turn":22,"step":1,"message":{"id":"preview-assistant-22","role":"assistant","content":[{"type":"text","text":"Checkpoint 22 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 22 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 22 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":130,"time":1787472000130}
|
||||
{"type":"step/end","data":{"turn":22,"step":1},"seq":131,"time":1787472000131}
|
||||
{"type":"turn/end","data":{"turn":22,"reason":{"kind":"completed"}},"seq":132,"time":1787472000132}
|
||||
{"type":"turn/start","data":{"turn":23},"seq":133,"time":1787472000133}
|
||||
{"type":"user/message","data":{"id":"preview-user-23","role":"user","content":[{"type":"text","text":"History checkpoint 23: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":134,"time":1787472000134}
|
||||
{"type":"step/start","data":{"turn":23,"step":1},"seq":135,"time":1787472000135}
|
||||
{"type":"assistant/message","data":{"turn":23,"step":1,"message":{"id":"preview-assistant-23","role":"assistant","content":[{"type":"text","text":"Checkpoint 23 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 23 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 23 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":136,"time":1787472000136}
|
||||
{"type":"step/end","data":{"turn":23,"step":1},"seq":137,"time":1787472000137}
|
||||
{"type":"turn/end","data":{"turn":23,"reason":{"kind":"completed"}},"seq":138,"time":1787472000138}
|
||||
{"type":"turn/start","data":{"turn":24},"seq":139,"time":1787472000139}
|
||||
{"type":"user/message","data":{"id":"preview-user-24","role":"user","content":[{"type":"text","text":"History checkpoint 24: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":140,"time":1787472000140}
|
||||
{"type":"step/start","data":{"turn":24,"step":1},"seq":141,"time":1787472000141}
|
||||
{"type":"assistant/message","data":{"turn":24,"step":1,"message":{"id":"preview-assistant-24","role":"assistant","content":[{"type":"text","text":"Checkpoint 24 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 24 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 24 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":142,"time":1787472000142}
|
||||
{"type":"step/end","data":{"turn":24,"step":1},"seq":143,"time":1787472000143}
|
||||
{"type":"turn/end","data":{"turn":24,"reason":{"kind":"completed"}},"seq":144,"time":1787472000144}
|
||||
{"type":"turn/start","data":{"turn":25},"seq":145,"time":1787472000145}
|
||||
{"type":"user/message","data":{"id":"preview-user-25","role":"user","content":[{"type":"text","text":"History checkpoint 25: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":146,"time":1787472000146}
|
||||
{"type":"step/start","data":{"turn":25,"step":1},"seq":147,"time":1787472000147}
|
||||
{"type":"assistant/message","data":{"turn":25,"step":1,"message":{"id":"preview-assistant-25","role":"assistant","content":[{"type":"text","text":"Checkpoint 25 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 25 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 25 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":148,"time":1787472000148}
|
||||
{"type":"step/end","data":{"turn":25,"step":1},"seq":149,"time":1787472000149}
|
||||
{"type":"turn/end","data":{"turn":25,"reason":{"kind":"completed"}},"seq":150,"time":1787472000150}
|
||||
{"type":"turn/start","data":{"turn":26},"seq":151,"time":1787472000151}
|
||||
{"type":"user/message","data":{"id":"preview-user-26","role":"user","content":[{"type":"text","text":"History checkpoint 26: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":152,"time":1787472000152}
|
||||
{"type":"step/start","data":{"turn":26,"step":1},"seq":153,"time":1787472000153}
|
||||
{"type":"assistant/message","data":{"turn":26,"step":1,"message":{"id":"preview-assistant-26","role":"assistant","content":[{"type":"text","text":"Checkpoint 26 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 26 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 26 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":154,"time":1787472000154}
|
||||
{"type":"step/end","data":{"turn":26,"step":1},"seq":155,"time":1787472000155}
|
||||
{"type":"turn/end","data":{"turn":26,"reason":{"kind":"completed"}},"seq":156,"time":1787472000156}
|
||||
{"type":"turn/start","data":{"turn":27},"seq":157,"time":1787472000157}
|
||||
{"type":"user/message","data":{"id":"preview-user-27","role":"user","content":[{"type":"text","text":"History checkpoint 27: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":158,"time":1787472000158}
|
||||
{"type":"step/start","data":{"turn":27,"step":1},"seq":159,"time":1787472000159}
|
||||
{"type":"assistant/message","data":{"turn":27,"step":1,"message":{"id":"preview-assistant-27","role":"assistant","content":[{"type":"text","text":"Checkpoint 27 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 27 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 27 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":160,"time":1787472000160}
|
||||
{"type":"step/end","data":{"turn":27,"step":1},"seq":161,"time":1787472000161}
|
||||
{"type":"turn/end","data":{"turn":27,"reason":{"kind":"completed"}},"seq":162,"time":1787472000162}
|
||||
{"type":"turn/start","data":{"turn":28},"seq":163,"time":1787472000163}
|
||||
{"type":"user/message","data":{"id":"preview-user-28","role":"user","content":[{"type":"text","text":"History checkpoint 28: verify deterministic preview state."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":164,"time":1787472000164}
|
||||
{"type":"step/start","data":{"turn":28,"step":1},"seq":165,"time":1787472000165}
|
||||
{"type":"assistant/message","data":{"turn":28,"step":1,"message":{"id":"preview-assistant-28","role":"assistant","content":[{"type":"text","text":"Checkpoint 28 is recorded."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["Checkpoint 28 is recorded."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Checkpoint 28 is recorded."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":166,"time":1787472000166}
|
||||
{"type":"step/end","data":{"turn":28,"step":1},"seq":167,"time":1787472000167}
|
||||
{"type":"turn/end","data":{"turn":28,"reason":{"kind":"completed"}},"seq":168,"time":1787472000168}
|
||||
{"type":"turn/start","data":{"turn":29},"seq":169,"time":1787472000169}
|
||||
{"type":"user/message","data":{"id":"preview-gallery-user","role":"user","content":[{"type":"text","text":"Show the seeded workspace, tool cards, subagents, and pagination in one tour."}],"source":{"kind":"user"}},"surfaceOp":"append","seq":170,"time":1787472000170}
|
||||
{"type":"step/start","data":{"turn":29,"step":1},"seq":171,"time":1787472000171}
|
||||
{"type":"assistant/message","data":{"turn":29,"step":1,"message":{"id":"preview-gallery-tools","role":"assistant","content":[{"type":"reasoning","text":""},{"type":"reasoning","text":" \t\n"},{"type":"reasoning","text":"I will inspect the deterministic workspace and collect each preview surface."},{"type":"reasoning","text":""},{"type":"reasoning","text":"Check the Chat presentation\n\nI will inspect the wrapped command, its first-line status, and the parent-path file link."},{"type":"reasoning","text":"\n "},{"type":"tool-call","id":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},{"type":"tool-call","id":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},{"type":"tool-call","id":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},{"type":"tool-call","id":"preview-wrapped-bash","name":"bash","arguments":"{\"command\":\"printf 'preview command fixture\\\\n' # wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-\\nprintf 'wrap complete\\\\n'\\n\",\"description\":\"Show wrapped command and first-line status\"}"},{"type":"tool-call","id":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},{"type":"tool-call","id":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},{"type":"tool-call","id":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},{"type":"tool-call","id":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},{"type":"tool-call","id":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},{"type":"tool-call","id":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},{"type":"tool-call","id":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":[""]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":1,"dt":[],"texts":[" \t\n"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"reasoning","text":" \t\n"}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":2,"dt":[],"texts":["I will inspect the deterministic workspace and collect each preview surface."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"reasoning","text":"I will inspect the deterministic workspace and collect each preview surface."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":3,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":3,"dt":[],"texts":[""]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":3,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":4,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":4,"dt":[],"texts":["Check the Chat presentation\n\nI will inspect the wrapped command, its first-line status, and the parent-path file link."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":4,"block":{"type":"reasoning","text":"Check the Chat presentation\n\nI will inspect the wrapped command, its first-line status, and the parent-path file link."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":5,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":5,"dt":[],"texts":["\n "]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":5,"block":{"type":"reasoning","textLine truncated
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-read","name":"read","arguments":"{\"file_path\":\"PREVIEW.md\"}"},"seq":173,"time":1787472000173}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-read-result","role":"tool","toolCallId":"preview-read","source":{"kind":"tool","callId":"preview-read"},"content":[{"type":"text","text":"<path>PREVIEW.md</path>\n<type>file</type>\n<content>\n1: # Preview Workspace\n2: \n3: This deterministic workspace is bundled with the browser-only preview.\n4: \n5: - `src/preview.ts` is the file changed by the example write result.\n6: - `data/tasks.json` mirrors the completed preview checklist.\n7: - `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing.\n8: \n9: Refresh the preview to restore these image bytes.\n\n(End of file - total 9 lines)\n</content>"}],"isError":false},"meta":{"path":"PREVIEW.md","offset":1,"lines":[{"number":1,"text":"# Preview Workspace"},{"number":2,"text":""},{"number":3,"text":"This deterministic workspace is bundled with the browser-only preview."},{"number":4,"text":""},{"number":5,"text":"- `src/preview.ts` is the file changed by the example write result."},{"number":6,"text":"- `data/tasks.json` mirrors the completed preview checklist."},{"number":7,"text":"- `.agents/skills/preview-tour/SKILL.md` proves dot directories survive image packing."},{"number":8,"text":""},{"number":9,"text":"Refresh the preview to restore these image bytes."}],"totalLines":9,"lang":"md"}},"surfaceOp":"append","seq":174,"time":1787472000174}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-write","name":"write","arguments":"{\"file_path\":\"src/preview.ts\",\"content\":\"export const previewStatus = 'ready'\\n\\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\\n\"}"},"seq":175,"time":1787472000175}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-write-result","role":"tool","toolCallId":"preview-write","source":{"kind":"tool","callId":"preview-write"},"content":[{"type":"text","text":"<path>src/preview.ts</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false},"meta":{"diffs":[{"path":"src/preview.ts","oldText":"export const previewStatus = 'draft'\n","newText":"export const previewStatus = 'ready'\n\nexport const previewFeatures = ['tools', 'subagents', 'pagination'] as const\n"}]}},"surfaceOp":"append","seq":176,"time":1787472000176}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-bash","name":"bash","arguments":"{\"command\":\"printf 'preview ready\\\\n'\",\"description\":\"Print the preview readiness marker\"}"},"seq":177,"time":1787472000177}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-bash-result","role":"tool","toolCallId":"preview-bash","source":{"kind":"tool","callId":"preview-bash"},"content":[{"type":"text","text":"preview ready\n"}],"isError":false}},"surfaceOp":"append","seq":178,"time":1787472000178}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-wrapped-bash","name":"bash","arguments":"{\"command\":\"printf 'preview command fixture\\\\n' # wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-wrapped-argument-\\nprintf 'wrap complete\\\\n'\\n\",\"description\":\"Show wrapped command and first-line status\"}"},"seq":179,"time":1787472000179}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-wrapped-bash-result","role":"tool","toolCallId":"preview-wrapped-bash","source":{"kind":"tool","callId":"preview-wrapped-bash"},"content":[{"type":"text","text":"preview command fixture\nwrap complete\n[exit code: 0]"}],"isError":false}},"surfaceOp":"append","seq":180,"time":1787472000180}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-glob","name":"glob","arguments":"{\"pattern\":\"**/*\",\"path\":\".\"}"},"seq":181,"time":1787472000181}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-glob-result","role":"tool","toolCallId":"preview-glob","source":{"kind":"tool","callId":"preview-glob"},"content":[{"type":"text","text":"PREVIEW.md\ndata/tasks.json\nsrc/preview.ts"}],"isError":false},"meta":{"shape":"paths","paths":["PREVIEW.md","data/tasks.json","src/preview.ts"],"truncated":false,"total":3}},"surfaceOp":"append","seq":182,"time":1787472000182}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-grep","name":"grep","arguments":"{\"pattern\":\"preview\",\"path\":\".\",\"include\":\"*.{md,ts,json}\"}"},"seq":183,"time":1787472000183}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-grep-result","role":"tool","toolCallId":"preview-grep","source":{"kind":"tool","callId":"preview-grep"},"content":[{"type":"text","text":"PREVIEW.md:3:This deterministic workspace is bundled with the browser-only preview.\nsrc/preview.ts:1:export const previewStatus = 'ready'"}],"isError":false},"meta":{"shape":"matches","files":[{"path":"PREVIEW.md","matches":[{"lineNumber":3,"line":"This deterministic workspace is bundled with the browser-only preview."}]},{"path":"src/preview.ts","matches":[{"lineNumber":1,"line":"export const previewStatus = 'ready'"}]}],"truncated":false,"total":2}},"surfaceOp":"append","seq":184,"time":1787472000184}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-web-search","name":"web_search","arguments":"{\"queries\":[\"Web Worker filesystem compatibility\"]}"},"seq":185,"time":1787472000185}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-web-search-result","role":"tool","toolCallId":"preview-web-search","source":{"kind":"tool","callId":"preview-web-search"},"content":[{"type":"text","text":"Browser workers can host deterministic in-memory filesystems.\n\nSources:\n1. MDN Web Workers API — https://developer.mozilla.org/docs/Web/API/Web_Workers_API"}],"isError":false},"meta":{"sources":[{"url":"https://developer.mozilla.org/docs/Web/API/Web_Workers_API","title":"Web Workers API","snippet":"Web Workers run scripts in background threads."}],"truncated":false,"answer":"Browser workers can host deterministic in-memory filesystems."}},"surfaceOp":"append","seq":186,"time":1787472000186}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-todo","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Inspect tool cards\",\"status\":\"completed\"},{\"content\":\"Open both subagents\",\"status\":\"completed\"},{\"content\":\"Load earlier history\",\"status\":\"in_progress\"}]}"},"seq":187,"time":1787472000187}
|
||||
{"type":"todo/write","data":{"todos":[{"content":"Inspect tool cards","status":"completed"},{"content":"Open both subagents","status":"completed"},{"content":"Load earlier history","status":"in_progress"}]},"seq":188,"time":1787472000188}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-todo-result","role":"tool","toolCallId":"preview-todo","source":{"kind":"tool","callId":"preview-todo"},"content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 2 completed."}],"isError":false}},"surfaceOp":"append","seq":189,"time":1787472000189}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent","name":"subagent","arguments":"{\"description\":\"Continue preview verification\",\"prompt\":\"Check the remaining preview cases.\",\"run_in_background\":true}"},"seq":190,"time":1787472000190}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-result","role":"tool","toolCallId":"preview-subagent","source":{"kind":"tool","callId":"preview-subagent"},"content":[{"type":"text","text":"started subagent preview-follow-up-builder"}],"isError":false}},"surfaceOp":"append","seq":191,"time":1787472000191}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-subagent-fork","name":"subagent_fork","arguments":"{\"description\":\"Review preview architecture\",\"prompt\":\"Review the fixture architecture.\",\"run_in_background\":false}"},"seq":192,"time":1787472000192}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-subagent-fork-result","role":"tool","toolCallId":"preview-subagent-fork","source":{"kind":"tool","callId":"preview-subagent-fork"},"content":[{"type":"text","text":"The preview fixture remains separate from user-owned WebFS data."}],"isError":false}},"surfaceOp":"append","seq":193,"time":1787472000193}
|
||||
{"type":"tool/call","data":{"turn":29,"step":1,"callId":"preview-failure","name":"read","arguments":"{\"file_path\":\"missing.txt\"}"},"seq":194,"time":1787472000194}
|
||||
{"type":"tool/result","data":{"turn":29,"step":1,"message":{"id":"preview-failure-result","role":"tool","toolCallId":"preview-failure","source":{"kind":"tool","callId":"preview-failure"},"content":[{"type":"text","text":"Error: ENOENT: no such file, open missing.txt"}],"isError":true},"error":{"name":"FsError","code":"ENOENT"}},"surfaceOp":"append","seq":195,"time":1787472000195}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"preview-architecture-review","childCreatedAt":1787472100000,"mode":"one-shot","label":"Review preview architecture"},"seq":196,"time":1787472000196}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"preview-follow-up-builder","childCreatedAt":1787472200000,"mode":"continuable","label":"Continue preview verification"},"seq":197,"time":1787472000197}
|
||||
{"type":"step/end","data":{"turn":29,"step":1},"seq":198,"time":1787472000198}
|
||||
{"type":"step/start","data":{"turn":29,"step":2},"seq":199,"time":1787472000199}
|
||||
{"type":"assistant/message","data":{"turn":29,"step":2,"message":{"id":"preview-gallery-final","role":"assistant","content":[{"type":"text","text":"## Preview tour complete\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.\n\nThis is fixture-authored demonstration data, not a live model run. Open [Parent workspace guide:1–8](<../workspace/PREVIEW.md#L1-L8>) to inspect the same workspace file through a parent path."}],"source":{"kind":"model","provider":"preview-fixture","model":"deterministic"}},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["## Preview tour complete\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.\n\nThis is fixture-authored demonstration data, not a live model run. Open [Parent workspace guide:1–8](<../workspace/PREVIEW.md#L1-L8>) to inspect the same workspace file through a parent path."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"## Preview tour complete\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.\n\nThis is fixture-authored demonstration data, not a live model run. Open [Parent workspace guide:1–8](<../workspace/PREVIEW.md#L1-L8>) to inspect the same workspace file through a parent path."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append","seq":200,"time":1787472000200}
|
||||
{"type":"step/end","data":{"turn":29,"step":2},"seq":201,"time":1787472000201}
|
||||
{"type":"turn/end","data":{"turn":29,"reason":{"kind":"completed"}},"seq":202,"time":1787472000202}
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
"sessions": {
|
||||
"preview-showcase": {
|
||||
"identity": {
|
||||
"formatVersion": 3,
|
||||
"formatVersion": 4,
|
||||
"createdAt": 1787472000000,
|
||||
"cwd": "/dsh/workspace",
|
||||
"isSeeded": false,
|
||||
@@ -17,7 +17,7 @@
|
||||
"rows": {
|
||||
"title": {
|
||||
"ver": 1,
|
||||
"seq": 198,
|
||||
"seq": 202,
|
||||
"val": "WebWorker Preview Showcase"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
buildVfsExampleFiles,
|
||||
VFS_EXAMPLE_OLDEST_MESSAGE,
|
||||
VFS_EXAMPLE_ROOT,
|
||||
VFS_EXAMPLE_REASONING,
|
||||
VFS_EXAMPLE_WRAPPED_COMMAND,
|
||||
VFS_EXAMPLE_SCHEDULE_IDS,
|
||||
VFS_EXAMPLE_SESSION_IDS,
|
||||
VFS_EXAMPLE_TAIL_MESSAGE,
|
||||
@@ -59,27 +61,29 @@ function textOf(event: SessionEvent): string {
|
||||
}
|
||||
|
||||
describe('WebWorker preview VFS example', () => {
|
||||
it('retains V3 inputs that strictly restore to the deterministic current output', () => {
|
||||
it('keeps the current showcase and strictly restorable historical child inputs beside their predecessors', () => {
|
||||
const expected = buildVfsExampleFiles()
|
||||
const currentName = generationLogFilename(SESSION_FORMAT_VERSION, 'none')
|
||||
const committedPath = (path: string): string => path.endsWith(`/${currentName}`)
|
||||
const main = `home/sessions/--dsh-workspace--/${VFS_EXAMPLE_SESSION_IDS.main}/`
|
||||
const committedPath = (path: string): string => path.endsWith(`/${currentName}`) && !path.startsWith(main)
|
||||
? `${path.slice(0, -currentName.length)}session.v3.jsonl`
|
||||
: path
|
||||
const predecessors = Object.values(VFS_EXAMPLE_SESSION_IDS)
|
||||
.map(id => `home/sessions/--dsh-workspace--/${id}/session.v2.jsonl`)
|
||||
predecessors.push(`${main}session.v3.jsonl`)
|
||||
expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].map(committedPath).concat(predecessors).sort())
|
||||
for (const [path, content] of expected) {
|
||||
if (path === 'home/storages/session_projcache.json') continue
|
||||
const committed = readFileSync(join(VFS_EXAMPLE_ROOT, committedPath(path)), 'utf8')
|
||||
if (path.endsWith(`/${currentName}`)) {
|
||||
if (path.endsWith(`/${currentName}`) && !path.startsWith(main)) {
|
||||
expect(restoreSession(committed, 3), path).toEqual(restoreSession(content, SESSION_FORMAT_VERSION))
|
||||
} else {
|
||||
expect(committed, path).toBe(content)
|
||||
if (path.startsWith(main)) restoreSession(committed, SESSION_FORMAT_VERSION)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('retains V3 cache identity while the generated cache uses the current writer', () => {
|
||||
it('keeps the projection cache aligned with the current showcase writer and tail', () => {
|
||||
const committed = JSON.parse(readFileSync(
|
||||
join(VFS_EXAMPLE_ROOT, 'home/storages/session_projcache.json'),
|
||||
'utf8',
|
||||
@@ -101,12 +105,10 @@ describe('WebWorker preview VFS example', () => {
|
||||
const generatedText = buildVfsExampleFiles().get('home/storages/session_projcache.json')
|
||||
if (generatedText === undefined) throw new Error('missing generated VFS example projection cache')
|
||||
const generated = JSON.parse(generatedText) as typeof committed
|
||||
const expectedCurrent = structuredClone(committed)
|
||||
for (const session of Object.values(expectedCurrent.tables.sessions)) {
|
||||
expect(session.identity.formatVersion).toBe(3)
|
||||
session.identity.formatVersion = SESSION_FORMAT_VERSION
|
||||
}
|
||||
expect(generated).toEqual(expectedCurrent)
|
||||
expect(generated).toEqual(committed)
|
||||
expect(generated.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]?.rows.title).toMatchObject({
|
||||
seq: readSession(VFS_EXAMPLE_SESSION_IDS.main).events.at(-1)?.seq,
|
||||
})
|
||||
expect(generated.unit).toEqual({
|
||||
name: projectionCacheDomainSpec.name,
|
||||
version: projectionCacheDomainSpec.version,
|
||||
@@ -180,6 +182,46 @@ describe('WebWorker preview VFS example', () => {
|
||||
expect(events.some(event => event.type === 'tool/result' && event.data.message.isError === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the 28-turn history prefix and both child catalog identities', () => {
|
||||
const main = readSession(VFS_EXAMPLE_SESSION_IDS.main)
|
||||
const child = readSession(VFS_EXAMPLE_SESSION_IDS.oneShot)
|
||||
const historical = restoreSession(readFileSync(join(VFS_EXAMPLE_ROOT,
|
||||
`home/sessions/--dsh-workspace--/${VFS_EXAMPLE_SESSION_IDS.main}/session.v3.jsonl`), 'utf8'), 3)
|
||||
const prefix = main.events.slice(0, child.inheritedEventCount)
|
||||
expect(prefix.filter(event => event.type === 'turn/start')).toHaveLength(28)
|
||||
expect(prefix).toEqual(historical.events.slice(0, child.inheritedEventCount))
|
||||
expect(child.events.slice(0, child.inheritedEventCount)).toEqual(prefix)
|
||||
expect(main.events.filter(event => event.type === 'subagent/catalog').map(event => event.data)).toEqual([
|
||||
{ version: 0, childId: VFS_EXAMPLE_SESSION_IDS.oneShot, childCreatedAt: 1_787_472_100_000,
|
||||
mode: 'one-shot', label: 'Review preview architecture' },
|
||||
{ version: 0, childId: VFS_EXAMPLE_SESSION_IDS.continuable, childCreatedAt: 1_787_472_200_000,
|
||||
mode: 'continuable', label: 'Continue preview verification' },
|
||||
])
|
||||
})
|
||||
|
||||
it('carries mixed reasoning, a wrapped command, and an authored parent-path link in the existing tour', () => {
|
||||
const { events } = readSession(VFS_EXAMPLE_SESSION_IDS.main)
|
||||
const gallery = events.find(event => event.type === 'assistant/message' && event.data.message.id === 'preview-gallery-tools')
|
||||
if (gallery?.type !== 'assistant/message') throw new Error('missing preview gallery message')
|
||||
const reasoning = gallery.data.message.content.flatMap(block => block.type === 'reasoning' ? [block.text] : [])
|
||||
expect(reasoning).toEqual(['', ' \t\n', VFS_EXAMPLE_REASONING[0], '', VFS_EXAMPLE_REASONING[1], '\n '])
|
||||
expect(reasoning.filter(text => text.trim() !== '')).toEqual(VFS_EXAMPLE_REASONING)
|
||||
const call = events.find(event => event.type === 'tool/call' && event.data.callId === 'preview-wrapped-bash')
|
||||
if (call?.type !== 'tool/call') throw new Error('missing wrapped command call')
|
||||
expect(JSON.parse(call.data.arguments)).toEqual({
|
||||
command: VFS_EXAMPLE_WRAPPED_COMMAND, description: 'Show wrapped command and first-line status',
|
||||
})
|
||||
expect(VFS_EXAMPLE_WRAPPED_COMMAND.split('\n')[0]?.length).toBeGreaterThan(400)
|
||||
expect(VFS_EXAMPLE_WRAPPED_COMMAND.endsWith('\n')).toBe(true)
|
||||
expect(events.find(event => event.type === 'tool/result' && event.data.message.source.callId === 'preview-wrapped-bash'))
|
||||
.toMatchObject({ data: { message: { isError: false, content: [{ type: 'text', text: 'preview command fixture\nwrap complete\n[exit code: 0]' }] } } })
|
||||
const tail = events.findLast(event => event.type === 'assistant/message')
|
||||
if (tail === undefined) throw new Error('missing preview tour tail')
|
||||
expect(textOf(tail)).toContain(`## ${VFS_EXAMPLE_TAIL_MESSAGE}`)
|
||||
expect(textOf(tail)).toContain('[Parent workspace guide:1–8](<../workspace/PREVIEW.md#L1-L8>)')
|
||||
expect(textOf(tail)).toContain('fixture-authored demonstration data, not a live model run')
|
||||
})
|
||||
|
||||
it('restores one-shot and continuable child Sessions with durable descriptors', () => {
|
||||
const expected = [
|
||||
[VFS_EXAMPLE_SESSION_IDS.oneShot, 'one-shot'],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { scheduleDomain } from '@deepseek-ai/dsh-schedule'
|
||||
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import { SUBAGENT_CATALOG_VERSION } from '@deepseek-ai/dsh-subagent/src/catalog.ts'
|
||||
|
||||
/** Root copied by the preview image's repository adapter. */
|
||||
export const VFS_EXAMPLE_ROOT = fileURLToPath(new URL('./fixtures/vfs-example', import.meta.url))
|
||||
@@ -37,6 +38,15 @@ export const VFS_EXAMPLE_OLDEST_MESSAGE = 'History checkpoint 01: verify determi
|
||||
/** Settled tail marker used by browser acceptance and the demonstration GIF. */
|
||||
export const VFS_EXAMPLE_TAIL_MESSAGE = 'Preview tour complete'
|
||||
|
||||
/** Non-empty reasoning bodies surrounded by empty and whitespace-only fixture blocks. */
|
||||
export const VFS_EXAMPLE_REASONING = [
|
||||
'I will inspect the deterministic workspace and collect each preview surface.',
|
||||
'Check the Chat presentation\n\nI will inspect the wrapped command, its first-line status, and the parent-path file link.',
|
||||
] as const
|
||||
|
||||
/** Authored long command and a second line for wrapping and status-dot acceptance. */
|
||||
export const VFS_EXAMPLE_WRAPPED_COMMAND = `printf 'preview command fixture\\n' # ${'wrapped-argument-'.repeat(28)}\nprintf 'wrap complete\\n'\n`
|
||||
|
||||
/** Durable task ids for the Host Schedule rows seeded into the preview's `DSH_HOME`. */
|
||||
export const VFS_EXAMPLE_SCHEDULE_IDS = {
|
||||
daily: 'preview-daily-digest',
|
||||
@@ -211,6 +221,12 @@ function galleryCalls(): GalleryCall[] {
|
||||
args: { command: "printf 'preview ready\\n'", description: 'Print the preview readiness marker' },
|
||||
result: 'preview ready\n',
|
||||
},
|
||||
{
|
||||
id: 'preview-wrapped-bash',
|
||||
name: 'bash',
|
||||
args: { command: VFS_EXAMPLE_WRAPPED_COMMAND, description: 'Show wrapped command and first-line status' },
|
||||
result: 'preview command fixture\nwrap complete\n[exit code: 0]',
|
||||
},
|
||||
{
|
||||
id: 'preview-glob',
|
||||
name: 'glob',
|
||||
@@ -327,7 +343,8 @@ function mainLog(): {
|
||||
log.add(userMessage('preview-gallery-user', 'Show the seeded workspace, tool cards, subagents, and pagination in one tour.'))
|
||||
log.add({ type: 'step/start', data: { turn, step: 1 } })
|
||||
log.add(assistantMessage('preview-gallery-tools', turn, 1, [
|
||||
{ type: 'reasoning', text: 'I will inspect the deterministic workspace and collect each preview surface.' },
|
||||
...['', ' \t\n', VFS_EXAMPLE_REASONING[0], '', VFS_EXAMPLE_REASONING[1], '\n ']
|
||||
.map(text => ({ type: 'reasoning', text })),
|
||||
...calls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: JSON.stringify(call.args) })),
|
||||
]))
|
||||
for (const call of calls) {
|
||||
@@ -355,11 +372,17 @@ function mainLog(): {
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
}
|
||||
for (const child of [
|
||||
{ childId: VFS_EXAMPLE_SESSION_IDS.oneShot, childCreatedAt: CREATED_AT + 100_000, mode: 'one-shot', label: 'Review preview architecture' },
|
||||
{ childId: VFS_EXAMPLE_SESSION_IDS.continuable, childCreatedAt: CREATED_AT + 200_000, mode: 'continuable', label: 'Continue preview verification' },
|
||||
] as const) {
|
||||
log.add({ type: 'subagent/catalog', data: { version: SUBAGENT_CATALOG_VERSION, ...child } })
|
||||
}
|
||||
log.add({ type: 'step/end', data: { turn, step: 1 } })
|
||||
log.add({ type: 'step/start', data: { turn, step: 2 } })
|
||||
log.add(assistantMessage('preview-gallery-final', turn, 2, [{
|
||||
type: 'text',
|
||||
text: `## ${VFS_EXAMPLE_TAIL_MESSAGE}\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.`,
|
||||
text: `## ${VFS_EXAMPLE_TAIL_MESSAGE}\n\nThe workspace, specialized tool cards, two subagent histories, and an earlier history page are ready to inspect.\n\nThis is fixture-authored demonstration data, not a live model run. Open [Parent workspace guide:1–8](<../workspace/PREVIEW.md#L1-L8>) to inspect the same workspace file through a parent path.`,
|
||||
}]))
|
||||
log.add({ type: 'step/end', data: { turn, step: 2 } })
|
||||
log.add({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
|
||||
Reference in new issue
Block a user