diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml new file mode 100644 index 0000000000..61b7a0e191 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md +2026-09-14-step-process-disclosures.md: f13cc17fe9ab95c6b20d05e385280751b41c8d1e +2026-09-14-step-process-disclosures.zh.md: d47fc4410bdc023fe5c6a7e661df54fc168524a5 diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md new file mode 100644 index 0000000000..f13cc17fe9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md @@ -0,0 +1,41 @@ +# Agent Note: Process disclosures between assistant responses + +Status: implemented + +English | [中文](2026-09-14-step-process-disclosures.zh.md) + +## Problem + +Whole-turn folding only shortens completed turns. Long ongoing turns interleave readable progress responses with tool and reasoning detail, obscuring the next response. + +## Decision + +The [Chat renderer](../../../../packages/client/ui-chat/README.md#turn-process-folding) groups consecutive process material between assistant responses. Reasoning and response blocks within one assistant node have separate renderer seats so streaming text closes the preceding process range without hiding the response. Group disclosure state survives updates under its first member key; the existing whole-turn disclosure remains the outer visibility owner. Its title reports elapsed time between the recorded turn boundaries; absent timestamps omit the duration. + +Compact and completed titles summarize tool categories, not individual tool names or infrastructure records. Every distinct call contributes once, including repeated paths. Count ranks the top three categories but stays out of the title; ties retain first appearance order. Two categories use “and”, three use commas, and only more than three add “etc.” Chinese uses “并”, “,” and “等”, sharing “已” only when both of two phrases start with it. English continuation phrases start lowercase. Running groups describe the newest running tool category, otherwise analysis activity, and animate that text with a repeating ease-in-out shimmer whose one-second pass is followed by a 500ms pause until the range closes. Detailed adds a concise current-call detail to the category's right, preferring authored task text and common command, query, URL, or path fields before falling back to the exact tool name. While no tool is running, the detail comes from the latest live reasoning paragraph. Details are whitespace-normalized and capped at 160 characters. Each displayed live title remains for at least 150ms; updates during that interval coalesce to the latest title instead of queuing intermediate tools; a completed group without tools uses the analysis fallback. Retry rows remain independent of secondary process groups. Each secondary title starts with the matching existing Tool icon; commands use Bash's `IconApiOutlineRegular`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, and no trailing chevron remains. Hovering whole-Turn and secondary disclosure titles darkens their text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages. An expanded title has 16px of space before its first process row, while adjacent process rows use 8px. Expanded content has a viewport-relative height cap and its own scroll area. Each scrollable edge carries a 24px fade that disappears when the reader reaches that edge. + +Whole-turn folding checks each Turn independently: while history is paginated, its start must be loaded. Requiring all Session history would disable folding throughout long goal and job sessions. Loading a missing start enables that Turn without waiting for unrelated older Turns. + +## Alternatives considered + +**Wait for turn completion.** This retains the clutter during the period when users read ongoing progress. + +**Treat each assistant node as an indivisible response.** Reasoning-only steps and reasoning preceding streamed text would remain outside the process disclosure. + +**Include infrastructure labels in summaries.** Prompt assembly, injected context, and permission metadata do not describe the model's work and make compact titles harder to scan. + +The whole-turn control appears at the start event and remains expanded and disabled during execution. Compact uses it as the sole turn-level activity label, displaying `Deep diving for …` (Chinese `深度求索中,用时…`) with a minimum of 1s and no leading icon; running, completed, stopped, and failed whole-Turn labels all use tertiary text at one pixel below the normal status size; Compact, Detailed, and Expanded use this whole-Turn control as the only turn-level running label. Its live clock refreshes once per second, shows whole seconds without padding single-digit seconds, and changes from minutes to hours at exactly 60 minutes. Normal completion fixes the clock, displays `Took …`, and defaults to collapsed. Cancellation and failure display `Stopped` and `Failed` respectively and remain expanded with a disabled control. + +Work details provides Compact, Detailed, and Expanded defaults for the two disclosure levels. Legacy compact settings remain Compact and normal settings resolve to Detailed on read; Expanded is a new option; new choices persist only current values. + +The Chat snapshot builder owns group layout, summaries, and footer eligibility. Content-only updates use a node-to-group index and preserve historical group identities. Renderer-seat boundary changes and pagination rebuild the layout. This keeps full-history grouping out of streaming renderers and makes footer selectors constant-time. A missing Turn start leaves its trailing group live until a response or recorded completion closes it. Lifecycle announcements use a separate polite status region so elapsed-time ticks remain silent. + +## Consequences + +The grouping changes presentation only and adds no Session events or model input. Tool names determine categories; unmatched custom tools use the generic category. Disclosure state is local to the mounted chat view. Closing the whole Turn resets nested process groups and remounts hidden process renderers so tool and detail disclosures start collapsed when reopened. Pagination that changes a group's first member may reset its expansion. Existing per-tool disclosures remain available inside the capped process area. + +Non-human Turn triggers use durable Inbox claims rather than every context injection. Next-turn claims and first-step next-step claims without a competing next-turn claim or human batch input receive independent notice seats above the process control. Policy injections do not qualify. Durable source-family fields supply the title and icon; presentation never parses producer-authored text for an outcome, and the expanded original content retains that detail. Unknown sources stay neutral. User-attributed programmatic requests cannot be distinguished reliably and keep their existing presentation. Notice expansion survives whole-Turn folding. + +Turn actions are anchored after the recorded Turn end so later reasoning, Tools, and steering stay above the footer. The latest Turn keeps actions visible only when its final content is a response; other endings and historical Turns reveal actions on hover or keyboard focus. The feedback target remains the last durable text response. + +Chat filters system prompts, ordinary context injections, and permission command rows before grouping, navigation, and visible-content checks. Filtering is presentation-only: durable records remain available to trajectory inspection. Waking context is classified as a separate turn-trigger notice and stays visible, because it explains why a new reply started. Metadata alone cannot create an empty process disclosure. diff --git a/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md new file mode 100644 index 0000000000..d47fc4410b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-step-process-disclosures.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Assistant 回复之间的过程折叠 + +Status: implemented + +[English](2026-09-14-step-process-disclosures.md) | 中文 + +## Problem + +整轮折叠只能缩短已经完成的轮次。较长的进行中轮次会在可读的进度回复之间穿插工具与推理详情,让用户难以找到下一条回复。 + +## Decision + +[Chat 渲染器](../../../../packages/client/ui-chat/README.zh.md#turn-process-folding) 将 Assistant 回复之间的连续过程内容分组。同一 Assistant 节点中的推理与回复块使用独立的渲染位置,因此流式正文会闭合前面的过程组,同时保持回复外露。分组以首个成员的键保持更新前后的展开状态,现有整轮折叠仍负责外层可见性。其标题展示记录的轮次起止时间之间的用时,时间戳缺失时省略时长。 + +简洁模式与完成后的标题只概括工具类别,不展示各工具名或基础设施记录。每个不同调用计数一次,同一路径的多次调用分别计数。次数仅用于选取前三类,不显示在标题中;并列时按首次出现顺序排列。两类用「并」,三类用「,」,仅超过三类才加「等」;两项均以「已」开头时共享首个「已」。英文对应 “and”、逗号和 “etc.”,后续短语首字母小写。进行中的组显示最新运行工具的类别,否则显示分析阶段,并在该组关闭前循环显示采用 ease-in-out 曲线、每次移动一秒并随后停顿 500ms 的文字扫光。详细模式在类别右侧加入简短的当前调用详情,优先采用调用方给出的任务文字及常见命令、查询词、URL 或路径字段,没有可用详情时回退到原始工具名;没有工具运行时,详情来自最新的实时思考段落。详情会合并空白并截断到 160 个字符。每个已显示的进行中标题至少保留 150ms;期间的更新只保留最新标题,不排队补播中间工具;完成组没有工具时使用分析完成的回退文案。重试行独立展示,不归入二级过程组。每个二级标题默认以前置的现有工具图标表示类别,命令类别使用 Bash 的 `IconApiOutlineRegular`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不显示箭头。悬停整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px。展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。展开内容按视口限制高度,并独立滚动;每个仍可滚动的方向显示 24px 渐隐遮罩,到达对应边缘时消失。 + +整轮折叠按轮次独立判断:历史分页未加载完时,该轮起点必须已加载。要求整个 Session 历史加载完整会让长时间运行的 goal 和 job 会话无法折叠。补齐起点即可启用该轮折叠,无需等待无关的更早轮次。 + +## Alternatives considered + +**等到轮次结束。** 用户阅读进行中的进度时,详情仍然占据大量空间。 + +**将每个 Assistant 节点都视为不可拆分的回复。** 纯推理步骤以及流式正文之前的推理会留在过程折叠之外。 + +**在摘要中加入基础设施标签。** 提示词组装、上下文注入与权限元数据不能描述模型工作,还会降低紧凑标题的可读性。 + +整轮控件在起点事件出现,执行期间始终展开且不可点击。简洁模式将它作为唯一的轮次运行状态,显示「深度求索中,用时…」,用时最少为 1 秒且不带前置图标;运行、完成、停止和失败的整轮状态文案均使用 tertiary 灰度,字号比普通状态小 1px;简洁、详细和完全展开均只使用该整轮控件显示轮次运行状态。其运行时钟每秒更新,显示整数秒且个位秒不补零,满 60 分钟切换为小时。正常完成后固定时钟,显示「用时…」,默认收起。取消和失败分别显示「已停止」与「处理失败」,始终展开且不可点击。 + +工作过程展示通过简洁、详细、完全展开三档控制两级分组的默认状态。旧 compact 设置保持简洁,normal 设置读取为详细;完全展开是新增选项;新选择仅保存当前三档值。 + +Chat 快照构建器负责分组布局、摘要和页脚展示条件。仅内容变化的更新通过节点到分组的索引定位,并保留历史分组引用。渲染位置边界变化和分页会重建布局。因此流式渲染器不再执行全历史分组,页脚选择器以常数时间读取结果。缺失轮次起点时,末尾分组保持进行中,直到回复或记录的完成事件将其闭合。生命周期通过单独的礼貌播报区域通知读屏器,耗时时钟跳动保持静默。 + +## Consequences + +分组仅改变展示,不增加 Session 事件或模型输入。分类由工具名决定,未匹配的自定义工具计入通用类别。展开状态保存在当前挂载的聊天视图中。收起整轮会重置内部过程组,并重新挂载已隐藏的过程 renderer,让工具和详情在重新打开时保持折叠。分页改变分组首个成员时可能重置展开状态。各工具原有的独立折叠仍可在限高过程区内使用。 + +非人工轮次触发原因以持久化 Inbox 领取记录为依据。next-turn 领取,以及没有竞争的 next-turn 领取、同批没有人工输入的首步 next-step 领取,在过程控件上方拥有独立提示 seat。策略注入不符合条件。标题和图标使用持久化的来源类别字段;展示层不从生产方正文解析结果,展开后的原始内容保留该信息。未知来源保持中性。归因为 user 的程序请求无法可靠区分,保留原有展示。提示展开状态不受整轮折叠影响。 + +轮次操作栏锚定在记录的轮次结束之后,使后续推理、工具和 steering 均位于页脚上方。最后一轮仅在最终内容为回复时常显操作;其他结尾和历史轮次通过悬停或键盘聚焦显示。反馈对象仍为最后一条持久化文字回复。 + +Chat 在分组、导航和可见内容检查之前过滤系统提示词、普通上下文注入和 permission 命令行。过滤仅影响展示,持久化记录仍可在轨迹中查看。唤起轮次的上下文被分类为独立轮次发起提示并保持可见,用于解释新回复的触发原因。只有元数据时不生成空过程折叠。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 69db5487b8..15a414e0a4 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2 -2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c +2026-09-17-developer-tools-settings.md: 38c002977f0368cf046ddb2121e5cddb3e36da8f +2026-09-17-developer-tools-settings.zh.md: e2b996950664ff57464c877c719469bbd66ca887 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index e76cb7f367..38c002977f 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -10,7 +10,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to ## Decision -The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting on. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. @@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority. -Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. +Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold preserves saved preferences or the product default unless a scenario explicitly sets the preference. Settings chrome, document preview, changed-files and developer-tools scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index 5a7d6d59db..e2b9969506 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认开启。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 @@ -26,4 +26,4 @@ Status: implemented 基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。 -组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 +组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架保留已保存的偏好或产品默认值,场景可显式设置偏好。设置外壳、文档预览、改动文件和开发者工具场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts index ce42436859..50ec72cb3b 100644 --- a/apps/web/tests/bash-abort-row.e2e.ts +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -43,6 +43,10 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await page.locator('[data-step-process] > button').first().waitFor() + for (const toggle of await page.locator('[data-step-process] > button[aria-expanded="false"]').all()) { + await toggle.click() + } await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 }) }, 120_000) diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index c539a96601..d97a9fcaf9 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -339,9 +339,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) expect(sessionEvents.flatMap(event => ( diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index af9ebc875f..e83e52b461 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -691,6 +691,7 @@ describe('web e2e: long Chat scroll contract', () => { await world.page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true) const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`) + await expandOwningTurnProcess(world.page, liveRow) await liveRow.waitFor({ timeout: 15_000 }) expect(await liveRow.getAttribute('data-state')).toBe('running') await expectBottom(world.page) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index 34ebcc8d1b..ae170a5e97 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -4,6 +4,7 @@ // without depending on CSS-module names or virtualizer DOM positions. import { ToolCallId, + type MessageSource, createAssistantMessage, createSystemMessage, createToolResultMessage, @@ -25,6 +26,10 @@ export interface ChatScrollFixtureOptions { readonly title: string /** Number of closed turns to generate. */ readonly turns?: number + /** Finish the last Turn after reasoning that follows its text response. */ + readonly stopAfterReasoning?: boolean + /** Optional non-human input claimed to start each Turn. */ + readonly wakingInput?: { readonly source: MessageSource; readonly content: string } } /** Semantic marker helpers returned with a generated fixture. */ @@ -196,24 +201,30 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`)) for (let turn = 1; turn <= turns; turn += 1) { + const input = createUserMessage({ + content: text(options.wakingInput?.content ?? + `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. ` + + 'Keep the visible message stable while history, tools, and new output change around it.'), + source: options.wakingInput?.source ?? { kind: 'user' }, + }) + if (options.wakingInput !== undefined) { + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] }) + } session.append('turn/start', { turn, }) + if (options.wakingInput !== undefined) { + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + } session.append('step/start', { turn, step: 1 }) // Native V3 installs the protected system head before any user surface. if (turn === 1) appendSystemPrompt(session, turn, 1) - const user = session.append('user/message', createUserMessage({ - content: text( - `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. ` - + 'Keep the visible message stable while history, tools, and new output change around it.', - ), - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) + const user = session.append('user/message', input, { surfaceOp: 'append' }) if (turn === 1) { session.append('session/title', { title: options.title, - messageSeqs: [user.seq], - source: { kind: 'fallback' }, + messageSeqs: options.wakingInput === undefined ? [user.seq] : [], + source: { kind: options.wakingInput === undefined ? 'fallback' : 'user' }, }) } @@ -243,7 +254,19 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat ) session.append('step/end', { turn, step: 1 }) } - session.append('turn/end', { turn, reason: { kind: 'completed' } }) + if (options.stopAfterReasoning === true && turn === turns) { + const step = turn % TOOL_INTERVAL === 0 ? 3 : 2 + session.append('step/start', { turn, step }) + appendRequestHeader(session, turn, step) + session.append('assistant/message', { turn, step, stream: [], message: createAssistantMessage({ + content: [{ type: 'reasoning', text: 'Inspect the remaining work before continuing.' }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step }) + session.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'user' } } }) + } else { + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } } return { log: fixtureLog(session), markers, title: options.title, turns } diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 21e4f78bae..fdd58aec6d 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -377,7 +377,10 @@ describe('web e2e: clickable links gallery', () => { // it opens first. Rows expand via a right-edge click: the row center can // land on the nested fileLink button, which would hand the path to the // Host's opener. - await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click() + await page.locator('[data-turn-process]').click() + for (const toggle of await page.locator('[data-step-process] > button[aria-expanded="false"]').all()) { + await toggle.click() + } for (const row of [ /^Search clickable link styles/, /^Fetch /, diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 16c0e6500a..b928016ddc 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */ +/** An explicit disabled setting overrides the Host default and survive browser reload through the ordinary settings UI. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { chromium } from 'playwright' @@ -16,7 +16,7 @@ it('persists developer tools in the Host settings document and restores the acce await page.getByRole('button', { name: 'Settings', exact: true }).click() const toggle = page.getByRole('switch', { name: 'Developer tools' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') - expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') diff --git a/apps/web/tests/diff-context.e2e.ts b/apps/web/tests/diff-context.e2e.ts index cf893ac7ec..f165d59435 100644 --- a/apps/web/tests/diff-context.e2e.ts +++ b/apps/web/tests/diff-context.e2e.ts @@ -1,4 +1,5 @@ /** Cold Session rendering covers exact context and bounded whole-fragment replacements. */ +import { expandOwningTurnProcess } from './support.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' @@ -51,6 +52,7 @@ describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => { const edit = page.locator('[data-variant="edit"]') expect(await edit.textContent()).toContain(scenario.totals) expect(await edit.locator('[data-diff]').count()).toBe(0) + await expandOwningTurnProcess(page, edit) await edit.locator('[data-expandable]').click() const card = edit.locator('[data-diff]') await card.waitFor() diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 67f77b8b26..b0bcd6abad 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -8,7 +8,9 @@ - tab "Trajectory" - text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" - button "Copy" -- button "16 tool calls" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Edited files, called tools, searched code, etc." [expanded] - button "Write site/report.html +1 -0": - text: Write - button "site/report.html" diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index db5236e711..85f31f56b2 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -10,14 +10,14 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" - button "Copy" -- button "3 tool calls" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] +- button "Think" - button "Inspect plugins 0 dynamic plugins" -- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." +- button "Think" - button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": - tab "Client" @@ -28,18 +28,22 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think The Host returned snap-1/pkg-1, so I will request its first activation." +- button "Think" - text: Run Cordis Plugin snap-1 · pkg-1 Ready - button "Inspect" - text: snap-1/pkg-1 is awaiting user approval (run-1). -- button "Think The activation request has been submitted, so I will return the requested readiness marker." +- button "Think" - paragraph: CORDIS_UI_READY - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" - text: 9/1 {{clock}} -- button "Thought for a while" +- button "Plugin status updated 9/1 {{clock}}": + - text: Plugin status updated + - time: 9/1 {{clock}} +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: The Cordis Plugin is running. - button "Copy" - button "Good response" @@ -47,7 +51,9 @@ - button "Branch into a new conversation" - text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}} - button "Copy" -- button "1 tool call" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] - text: Stop Cordis Plugin snap-1 - button "Inspect" - text: Dynamic Plugin snap-1 is stopped; its definition and versions remain. diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 610d173e1e..2d1761e468 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -15,10 +15,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" -- button "Thought for a while" [expanded] -- button "Context injection webhook github webhook handled by review-pr-when-ready" -- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "GitHub event received {{clock}}": + - text: GitHub event received + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 7167daea13..2d1761e468 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -15,8 +15,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" -- button "Thought for a while" +- button "GitHub event received {{clock}}": + - text: GitHub event received + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: "Review complete: no actionable findings." - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index c8f054e060..c1e6e3081e 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -8,6 +8,8 @@ - tab "Trajectory" - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index be7317eb3e..e13fe9df2a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -8,6 +8,8 @@ - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index c637b199dd..f59938c9d4 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -7,6 +7,8 @@ - tab "Trajectory" - text: Show the local preview URL. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index faab58a5b2..5210d625a5 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -8,6 +8,8 @@ - tab "Trajectory" - text: Render this mathematical proof. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index ac92c645bf..e7d64552b0 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -8,7 +8,8 @@ - tab "Trajectory" - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy" -- button "Session recall Research notes" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index ace79f9802..9450196487 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -19,7 +19,7 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Conversation display Controls process content in completed turns + - text: px Work details Controls how turns and steps expand by default - button "Compact" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 212fabd235..0123d1872f 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -19,8 +19,8 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑" + - text: px 工作过程展示 控制轮次和步骤的默认展开方式 + - button "简洁" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 49ff06f442..b7b4f11f14 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -7,14 +7,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Context injection user-invoke-demo" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index dc6265c650..b7b4f11f14 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -7,12 +7,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 579e3689d4..f9233e2961 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -37,6 +37,8 @@ - button "Jump to turn 28" - text: m1 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r1 - button "Copy" - button "Good response" @@ -44,6 +46,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m2 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r2 - button "Copy" - button "Good response" @@ -51,6 +55,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m3 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r3 - button "Copy" - button "Good response" @@ -58,6 +64,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m4 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r4 - button "Copy" - button "Good response" @@ -65,6 +73,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m5 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r5 - button "Copy" - button "Good response" @@ -72,6 +82,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m6 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r6 - button "Copy" - button "Good response" @@ -79,6 +91,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m7 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r7 - button "Copy" - button "Good response" @@ -86,6 +100,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m8 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r8 - button "Copy" - button "Good response" @@ -93,6 +109,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m9 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r9 - button "Copy" - button "Good response" @@ -100,6 +118,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m10 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r10 - button "Copy" - button "Good response" @@ -107,6 +127,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m11 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r11 - button "Copy" - button "Good response" @@ -114,6 +136,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m12 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r12 - button "Copy" - button "Good response" @@ -121,6 +145,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m13 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r13 - button "Copy" - button "Good response" @@ -128,6 +154,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m14 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r14 - button "Copy" - button "Good response" @@ -135,6 +163,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m15 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r15 - button "Copy" - button "Good response" @@ -142,6 +172,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m16 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r16 - button "Copy" - button "Good response" @@ -149,6 +181,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m17 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r17 - button "Copy" - button "Good response" @@ -156,6 +190,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m18 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r18 - button "Copy" - button "Good response" @@ -163,6 +199,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m19 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r19 - button "Copy" - button "Good response" @@ -170,6 +208,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m20 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r20 - button "Copy" - button "Good response" @@ -177,6 +217,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m21 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r21 - button "Copy" - button "Good response" @@ -184,6 +226,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m22 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r22 - button "Copy" - button "Good response" @@ -191,6 +235,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m23 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r23 - button "Copy" - button "Good response" @@ -198,6 +244,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m24 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r24 - button "Copy" - button "Good response" @@ -205,6 +253,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m25 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r25 - button "Copy" - button "Good response" @@ -212,6 +262,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m26 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r26 - button "Copy" - button "Good response" @@ -219,6 +271,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m27 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r27 - button "Copy" - button "Good response" @@ -226,6 +280,8 @@ - button "Branch into a new conversation" - text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: r28 - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 53fd9f27dc..d6424db6ca 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -7,18 +7,27 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- button "Waiting for your action" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - text: "Interjection: include the word ORANGE in your final reply." - button "Copy" -- textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands" -- 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash -- button "Stop generating" +- region "Ready to continue?": + - text: Checkpoint + - heading "Ready to continue?" [level=2] + - button "Collapse the question card" [expanded] + - button "Dismiss all questions" + - radiogroup: + - radio "Yes": 1 Yes + - radio "No": 2 No + - textbox "Type your answer" + - button "Previous question" [disabled] + - text: 1 / 1 + - button "Next question" [disabled] + - status + - button "Skip" + - button "Submit" [disabled] diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 4e6fcc0ac8..f6390723d4 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -7,13 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." -- button "Ask question 1/1 answered" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 28daa440d7..a230122bf6 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" diff --git a/apps/web/tests/expected/step-process/collapsed.md b/apps/web/tests/expected/step-process/collapsed.md new file mode 100644 index 0000000000..fb923cb1ce --- /dev/null +++ b/apps/web/tests/expected/step-process/collapsed.md @@ -0,0 +1 @@ +- button "Ran commands" diff --git a/apps/web/tests/expected/step-process/empty-turn.md b/apps/web/tests/expected/step-process/empty-turn.md new file mode 100644 index 0000000000..447a9fa329 --- /dev/null +++ b/apps/web/tests/expected/step-process/empty-turn.md @@ -0,0 +1 @@ +- button "Took 1s" [disabled] diff --git a/apps/web/tests/expected/step-process/stopped-footer.md b/apps/web/tests/expected/step-process/stopped-footer.md new file mode 100644 index 0000000000..cb6bcde7a7 --- /dev/null +++ b/apps/web/tests/expected/step-process/stopped-footer.md @@ -0,0 +1,5 @@ +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" [disabled] +- text: Available only on the last message of a completed turn HH:mm diff --git a/apps/web/tests/expected/step-process/think-collapsed.md b/apps/web/tests/expected/step-process/think-collapsed.md new file mode 100644 index 0000000000..c568b8fcaf --- /dev/null +++ b/apps/web/tests/expected/step-process/think-collapsed.md @@ -0,0 +1 @@ +- button "Think" diff --git a/apps/web/tests/expected/step-process/trigger-collapsed.md b/apps/web/tests/expected/step-process/trigger-collapsed.md new file mode 100644 index 0000000000..49e70f8c16 --- /dev/null +++ b/apps/web/tests/expected/step-process/trigger-collapsed.md @@ -0,0 +1,3 @@ +- button "Background task updated HH:mm": + - text: Background task updated + - time: HH:mm diff --git a/apps/web/tests/expected/step-process/trigger-expanded.md b/apps/web/tests/expected/step-process/trigger-expanded.md new file mode 100644 index 0000000000..374d75254e --- /dev/null +++ b/apps/web/tests/expected/step-process/trigger-expanded.md @@ -0,0 +1,5 @@ +- button "Background task updated HH:mm" [expanded]: + - text: Background task updated + - time: HH:mm +- paragraph: This notification triggered this response. +- text: "background job bash-39 (bash: PR merge check) finished [status: completed, exit code 0]. Read its output with job_output." diff --git a/apps/web/tests/expected/step-process/turn-collapsed.md b/apps/web/tests/expected/step-process/turn-collapsed.md new file mode 100644 index 0000000000..d1587c94d9 --- /dev/null +++ b/apps/web/tests/expected/step-process/turn-collapsed.md @@ -0,0 +1 @@ +- button "Took 1s" diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 9b6ebf857f..1361a476d3 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -155,9 +155,9 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2948072fd0..89eeddeea4 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -328,18 +328,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await input.press('Enter') if (MODE !== 'record') { const thinking = page.locator('[data-variant="think"][data-state="running"]') + await expandOwningTurnProcess(page, thinking) await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') - const liveTail = thinking.locator('[data-follow-end]') - await expect.poll(async () => { - if (await liveTail.count() !== 1) return false - return await liveTail.evaluate((element) => { - const text = element.firstElementChild - if (!(text instanceof HTMLElement)) return false - const viewport = element.getBoundingClientRect() - const content = text.getBoundingClientRect() - return content.width > viewport.width && Math.abs(content.right - viewport.right) <= 1 - }) - }, { timeout: 10_000, interval: 10 }).toBe(true) + expect(await thinking.getByRole('button').innerText()).toContain('Think') + // This replay holds an unfinished first line, before a paragraph preview is available. + expect(await thinking.locator('[class*="summaryText"]').count()).toBe(0) } observedReasoning.resolve(undefined) return await settled diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index fa69b3644e..cc724f33c8 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -156,10 +156,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // The marker IS the synchronization: the stream is provably parked in the // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) - await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), - { timeout: 10_000 }, - ).toBe(true) + const liveControl = page.locator('[data-turn-process]') + expect(await liveControl.isDisabled()).toBe(true) + const liveLabel = await liveControl.textContent() + expect(liveLabel).toMatch(/^Deep diving for /) + await expect.poll(() => liveControl.textContent(), { timeout: 10_000 }).not.toBe(liveLabel) + expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(1) await page.locator('[data-streaming="true"]') .getByText('partial', { exact: true }) .waitFor({ timeout: 30_000 }) @@ -190,6 +192,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') + await expect.poll(() => liveControl.textContent()).toBe('Stopped') + expect(await liveControl.isDisabled()).toBe(true) // Composer recovered; no streaming node lingers. The host settled first // (awaited above), but the abort frame reaches the browser over SSE — the // frozen-partial swap is eventually consistent, so poll rather than count. @@ -295,6 +299,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { scaffold!.workspaceCwd, ) await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE) + await page.locator('[data-turn-process]').click() + await page.getByRole('status').filter({ hasText: 'Retried model request' }).waitFor() + expect(await page.locator('[data-step-process]').getByRole('status').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 120_000) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 85a16c58a5..75f631a318 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -227,7 +227,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll( () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), { timeout: 10_000 }, - ).toBe(2) + ).toBe(0) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 22ab1bc476..a3b37c2b29 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -19,7 +19,7 @@ import { 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/minimal-preset', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -154,6 +154,8 @@ describe('minimal agent preset', () => { await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true') const row = page.locator('[data-sample="bash"]').first() + await expect.poll(() => row.isVisible()).toBe(false) + await expandOwningTurnProcess(page, row) await row.waitFor({ timeout: 15_000 }) await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') await row.click() diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index bf8eb5e1e8..ac3edeea42 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -1,4 +1,5 @@ /** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */ +import { expandOwningTurnProcess } from './support.ts' import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises' import { join, delimiter } from 'node:path' import { fileURLToPath } from 'node:url' @@ -181,6 +182,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const delivered = page.locator('[data-tool="present"][data-state="ok"]') expect(await failed.count()).toBe(1) expect(await delivered.count()).toBe(1) + await expandOwningTurnProcess(page, failed) + await expandOwningTurnProcess(page, delivered) expect(await failed.innerText()).toContain('Delivery failed') expect(await delivered.innerText()).toContain('Delivered') await page.locator('[data-turn-process]').click() diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index f27edc18d8..8e92c729a0 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -16,6 +16,7 @@ * The site is served the way a static host serves it: bytes from `dist/` with * no rewrite rules, so a missing file is a 404 rather than the index page. */ +import { expandOwningTurnProcess } from './support.ts' import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { once } from 'node:events' @@ -418,6 +419,9 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) + for (const row of await page.locator('[data-chat-flow-kind="tool-call"]').all()) { + await expandOwningTurnProcess(page, row) + } await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor() await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor() await page.getByText('Update to-do list', { exact: true }).waitFor() diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 54bcc461c9..2a266062cc 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -128,6 +128,8 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') await row.click() await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + expect(await row.locator('svg path').first().getAttribute('d')) + .toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10') const terminal = row.locator('xpath=..').locator('[data-terminal]') await terminal.waitFor() await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor() diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index 6069367555..b15aa9ec7f 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -9,6 +9,7 @@ // and no event references the workspace, so the lane replays on any host // with a usable `pwsh` — the lane mounts the pwsh stack through an overlay // (the shipped tree keeps the bash stack). +import { expandOwningTurnProcess } from './support.ts' import { spawnSync } from 'node:child_process' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -81,6 +82,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bas // The tool row is expand-gated: the settled row uses the bash layout and carries the // shell-family variant, and the terminal card lives in the expanded body. const row = page.locator('[data-tool="pwsh"]').first() + await expandOwningTurnProcess(page, row) await row.waitFor({ timeout: 15_000 }) if (await row.getAttribute('aria-expanded') !== 'true') await row.click() const card = page.locator('[data-terminal]').first() diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 260792f545..3d704d931e 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -7,6 +7,7 @@ // chunks, the composer wait is real, and the answer click is the test's own // gesture (the ONE place a drive step legitimately reacts to model content: // the turn cannot complete without it, in record and replay alike). +import { expandOwningTurnProcess } from './support.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -299,6 +300,7 @@ describe('web e2e: resident question composer round trip', () => { // Keep the ask_user_question card's readable answer in the expanded golden // even though Compact mode hides the process by default. await expandTurnProcesses(page) + await expandOwningTurnProcess(page, page.locator('[data-tool="ask_user_question"]').first()) const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) await answeredRow.click() await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) @@ -389,6 +391,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () it('expands to the cancellation verdict and original questions', async () => { onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row')) const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true }) + await expandOwningTurnProcess(cancelledPage, cancelledPage.locator('[data-tool="ask_user_question"]')) await row.waitFor({ timeout: 15_000 }) await row.click() diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index c447548421..eceb8e2ab8 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -390,7 +390,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(tripwire.warnings).toEqual([]) }) - it('renders the durable direct-message then recall order', async () => { + it('renders the direct message without an infrastructure recall row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order')) const group = page.getByRole('treeitem', { name: /Ungrouped/ }) await group.waitFor({ timeout: 15_000 }) @@ -402,12 +402,13 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const target = groupSection.locator('[role="treeitem"]').nth(1) await target.waitFor({ timeout: 15_000 }) await target.click() - await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 }) + await page.getByText('what changed?', { exact: false }).first().waitFor({ timeout: 15_000 }) + expect(await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).count()).toBe(0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(TARGET_SESSION_ID).join('{{targetId}}') await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE) - expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes')) + expect(snapshot).toContain('Research notes what changed?') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md']) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index b4e78ec07e..b310b28d95 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -22,7 +22,7 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, + connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url)) @@ -202,24 +202,10 @@ describe('web e2e: fresh round trip through the real assembly', () => { await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) - it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt')) + it.skipIf(MODE === 'record')('keeps system prompt infrastructure out of expanded Chat', async () => { await expandTurnProcesses(page) - const disclosure = page.getByRole('button', { name: 'System prompt', exact: true }) - const body = page.locator('[data-system-prompt-body]') - await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - expect(await body.count()).toBe(0) - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const opaque = body.locator('[data-context-text]') - await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1) - expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.') - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => body.count()).toBe(0) + expect(await page.locator('[data-chat-flow-kind="system-prompt"]').count()).toBe(0) + expect(await page.getByRole('button', { name: 'System prompt', exact: true }).count()).toBe(0) }) it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { @@ -229,7 +215,9 @@ describe('web e2e: fresh round trip through the real assembly', () => { // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. await expandTurnProcesses(page) - const think = page.getByRole('button', { name: /^Think/ }).first() + const reasoning = page.locator('[data-variant="think"]').first() + await expandOwningTurnProcess(page, reasoning) + const think = reasoning.getByRole('button').first() expect(await think.getAttribute('aria-expanded')).toBe('false') await think.click() await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 764381c70e..5a84363b17 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -302,7 +302,7 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { - /** The scaffold enables developer tools unless false preserves the shipped default. */ + /** Explicit developer-tool preference; omitted keeps the saved value or product default. */ developerTools?: boolean /** Profile resolver backend used by this test Host; defaults to runtime coverage. */ profileResolutionMode?: Extract @@ -824,8 +824,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { }], }, }), { surfaceOp: 'append' }) - // The header names the producer the durable source records, so the - // reconciled instruction file is readable without expanding the row. - await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) - .waitFor({ timeout: 10_000 }) + expect(await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }).count()).toBe(0) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -383,8 +380,16 @@ describe('web e2e: seeded history renders through cold resume', () => { const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true' if (!wasExpanded) await turnProcess.click() const thinking = page.locator('[data-variant="think"]').first() + await expandOwningTurnProcess(page, thinking) const toggle = thinking.getByRole('button').first() - const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize) + const secondarySize = await thinking.evaluate((element) => { + const probe = document.createElement('span') + probe.style.fontSize = 'var(--dsh-content-font-size-secondary, 13px)' + element.append(probe) + const size = getComputedStyle(probe).fontSize + probe.remove() + return size + }) await toggle.click() try { await thinking.locator('p').waitFor({ timeout: 10_000 }) @@ -411,60 +416,9 @@ describe('web e2e: seeded history renders through cold resume', () => { } }) - it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection')) - const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }) - expect(await disclosure.getAttribute('aria-expanded')).toBe('false') - const collapsedIcon = disclosure.locator('svg').first() - const collapsedIconBox = await collapsedIcon.boundingBox() - expect(collapsedIconBox?.width).toBe(14) - expect(collapsedIconBox?.height).toBe(14) - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const body = page.locator('[data-context-injection-body]') - await body.waitFor({ timeout: 5_000 }) - // The instructions form names the file it reconciled above the text, and - // the text keeps the framing the model read rather than a cleaned excerpt. - expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded']) - expect(await body.locator('[data-context-text]').innerText()).toContain('') - const headerBox = await disclosure.boundingBox() - const bodyBox = await body.boundingBox() - if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable') - expect(headerBox.height).toBe(24) - expect(bodyBox.x - headerBox.x).toBe(22) - expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4) - expect(bodyBox.height).toBe(141) - - const style = await body.evaluate((element) => { - const computed = getComputedStyle(element) - return { - backgroundColor: computed.backgroundColor, - borderRadius: computed.borderRadius, - color: computed.color, - fontSize: computed.fontSize, - lineHeight: computed.lineHeight, - padding: [ - computed.paddingTop, - computed.paddingRight, - computed.paddingBottom, - computed.paddingLeft, - ], - scrolls: element.scrollHeight > element.clientHeight, - } - }) - expect(style).toEqual({ - backgroundColor: 'rgb(249, 250, 251)', - borderRadius: '8px', - color: 'rgb(129, 133, 140)', - fontSize: '11px', - lineHeight: '16px', - padding: ['10px', '16px', '12px', '12px'], - scrolls: true, - }) - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + it.skipIf(MODE === 'record')('omits infrastructure context from expanded Chat', async () => { + expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) + expect(await page.locator('[data-context-injection-body]').count()).toBe(0) }) it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => { @@ -712,24 +666,17 @@ describe('web e2e: seeded history renders through cold resume', () => { if (bodyError !== undefined) throw bodyError }) - it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) - // The Access chip submits `/permission ` — a host command with no - // model call, so the settled row renders keylessly over this cold history. - // The row copy is the assertion: `permission · preset read-only`, - // where neither half repeats the other (the dispatched `/` and its - // argument stay out of the title, and the settlement text never restates - // the command's own name). + it.skipIf(MODE === 'record')('persists an Access-chip switch without a permission row in Chat', async () => { await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click() await page.getByRole('menuitem', { name: 'Read Only' }).click() const access = page.getByRole('button', { name: 'Access mode, current: Read Only' }) await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true) - // Scoped to the row itself, so unrelated page text that happens to read - // `permission` (a future resident slash menu) cannot satisfy or break it. + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + await expect.poll(() => agent.session.snapshotEvents().some(event => + event.type === 'command/done' && event.data.text?.includes('preset read-only'))).toBe(true) const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' }) - await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) - expect(await row.getByText('permission', { exact: true }).count()).toBe(1) - expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0) + expect(await row.count()).toBe(0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) @@ -776,28 +723,6 @@ describe('web e2e: seeded history renders through cold resume', () => { } }, 60_000) - it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => { - const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) - if (agent === undefined) throw new Error('seeded session did not attach an agent') - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Short injected context.' }], - source: { kind: 'plugin', plugin: 'fixture' }, - }), { surfaceOp: 'append' }) - - const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true }) - await disclosure.waitFor({ timeout: 10_000 }) - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - - // The instructions row above stays expanded from the geometry case; the - // opaque body is the one without a declared form. - const body = page.locator('[data-context-injection-body]:not([data-context-form])') - const bodyBox = await body.boundingBox() - if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable') - expect(bodyBox.height).toBeLessThan(141) - expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false) - }) - it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-sidebar-reload')) await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b396d5f969..b838ab2ee8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -503,12 +503,12 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '紧凑', exact: true }).click() - await page.getByRole('menuitem', { name: '标准', exact: true }).click() - await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '简洁', exact: true }).click() + await page.getByRole('menuitem', { name: '完全展开', exact: true }).click() + await dialog.getByRole('button', { name: '完全展开', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toMatch(/ui-chat:\n\s+transcriptView: expanded/) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -517,11 +517,11 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) - await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '完全展开', exact: true }).waitFor({ timeout: 10_000 }) - await reloaded.getByRole('button', { name: '标准', exact: true }).click() - await page.getByRole('menuitem', { name: '紧凑', exact: true }).click() - await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '完全展开', exact: true }).click() + await page.getByRole('menuitem', { name: '简洁', exact: true }).click() + await reloaded.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-chat:\n\s+transcriptView: compact/) await page.keyboard.press('Escape') diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index b9b436b27a..ec7513f685 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -508,7 +508,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(await page.getByRole('alert').count()).toBe(0) expect(alive(original)).toBe(true) const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url)) - await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode()) + await compareOrRefreshGolden(disconnected, await page.locator('[data-sidebar-terminal]').getByRole('status').ariaSnapshot(), webSnapshotMode()) await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true }) await reconnect.click() transport.reconnect() diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 93d264d3e3..49d71b7045 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -3,7 +3,7 @@ // skill.invoke: the real host forwards the gesture as an ordinary user // prompt, injects the rendered body as instructions context named after the // skill, and starts a turn answered by the replay adapter. The transcript shows -// the gesture bubble, the collapsed context-injection row, and the reply. +// the gesture bubble and reply; injected instructions remain in the Session log. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -11,6 +11,7 @@ import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { deriveEventMessage } from '@deepseek-ai/dsh-session' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { assertFixtureInventory, @@ -22,7 +23,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') @@ -99,7 +100,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') }) - it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { + it('claims /name args into a gesture bubble, logged instructions, and a replayed answer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) const composer = page.locator('[data-composer-input][contenteditable="true"]').last() await composer.waitFor({ timeout: 15_000 }) @@ -122,31 +123,21 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await bubble.waitFor({ timeout: 15_000 }) expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - // The rendered body arrives as a context-injection row named after the - // skill. Context plus the final answer contributes no summary count, so - // the Turn uses the fallback title while the row's own disclosure remains usable. - const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME }) - await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 }) await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) - await settled - const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) - await process.waitFor({ state: 'visible', timeout: 10_000 }) - // The chip derives from the step's logged injection, so it must survive - // every later Node rebuild of the Turn (process publication, turn close). + const sessionId = await settled + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('skill invocation did not attach an agent') + const instructions = agent.session.snapshotEvents().flatMap((event) => { + const message = deriveEventMessage(event) + return message?.role === 'user' && message.source.kind !== 'user' + ? message.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : [] + }).find(text => text.includes(``)) + expect(instructions).toContain('Reply with the fixture acknowledgement line.') + expect(instructions).not.toContain(ARGS_TEXT) expect(await bubble.count()).toBe(1) expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) - await expandOwningTurnProcess(page, injectionFlow) - const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) - await injectionRow.click() - const injectionBody = page - .locator('[data-context-injection-body]') - .filter({ hasText: `` }) - await injectionBody.waitFor({ timeout: 10_000 }) - const injected = await injectionBody.textContent() - expect(injected).toContain('Reply with the fixture acknowledgement line.') - expect(injected).not.toContain(ARGS_TEXT) - await injectionRow.click() - await process.click() + expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 9564bc493e..938fbad536 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -7,14 +7,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] - text: ts - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" -- status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 38c9bf71cf..fc87c20cbf 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -397,7 +397,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { // The reasoning row streams independently of the steering handoff. Wait // for the block to settle so the mid snapshot does not race its transient // visually-hidden Running label while the question keeps the turn open. - await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 }) + await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ state: 'attached', timeout: 10_000 }) const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) diff --git a/apps/web/tests/step-process.e2e.ts b/apps/web/tests/step-process.e2e.ts new file mode 100644 index 0000000000..b70c9b6153 --- /dev/null +++ b/apps/web/tests/step-process.e2e.ts @@ -0,0 +1,319 @@ +/** Real-composition coverage of nested process disclosure geometry and accessible summaries. */ +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +it('keeps completed step work collapsed inside an expanded turn and bounds its scroll area', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'STEP_PROCESS', title: 'Step process disclosure', turns: 88 }) + const scaffold = await launchWebScaffold({}) + try { + const log = fixture.log.trimEnd().split('\n').map((line, index) => { + if (index === 0) return line + const event = JSON.parse(line) as Record + return JSON.stringify({ ...event, time: 1_800_000_000_000 }) + }).join('\n') + '\n' + await seedSession(scaffold, log, 'step-process-e2e') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser, 900) + await page.goto(scaffold.authenticatedUrl) + await page.getByText('Ungrouped', { exact: true }).waitFor() + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() + const outer = page.locator('[data-turn-process="88"]') + await outer.waitFor() + expect(await page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(1) + expect(await outer.textContent()).toMatch(/^Took /) + await page.mouse.move(0, 0) + const outerColor = await outer.evaluate(element => getComputedStyle(element).color) + expect(await outer.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await outer.hover() + await expect.poll(() => outer.evaluate( + (element, initialColor) => getComputedStyle(element).color === initialColor, + outerColor, + )).toBe(false) + expect(await outer.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/turn-collapsed.md', import.meta.url)), + await outer.ariaSnapshot(), webSnapshotMode()) + await outer.click() + const group = page.locator('[data-step-process][data-chat-turn="88"]').first() + const toggle = group.getByRole('button', { name: 'Ran commands', exact: true }) + await toggle.waitFor() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + const activityIcon = toggle.locator('[data-step-process-icon]') + const chevron = toggle.locator('[data-step-process-chevron]') + await page.mouse.move(0, 0) + const toggleColor = await toggle.evaluate(element => getComputedStyle(element).color) + expect(await toggle.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await toggle.hover() + await expect.poll(() => toggle.evaluate( + (element, initialColor) => getComputedStyle(element).color === initialColor, + toggleColor, + )).toBe(false) + expect(await toggle.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('0px') + expect(await group.locator('[data-step-process-body]').getAttribute('hidden')).toBe('until-found') + const nextMessage = group.locator('xpath=following-sibling::*[1]') + expect(await nextMessage.getAttribute('data-chat-flow-kind')).toBe('assistant-step') + expect(await group.evaluate((element) => { + const next = element.nextElementSibling + return next === null ? null : next.getBoundingClientRect().top - element.getBoundingClientRect().bottom + })).toBe(16) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/collapsed.md', import.meta.url)), + await group.ariaSnapshot(), webSnapshotMode()) + await toggle.click() + const body = group.locator('[data-step-process-body]') + expect(await body.getAttribute('hidden')).toBeNull() + await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('16px') + const leafSpacing = await body.evaluate((element) => { + const rows = [...element.children].filter(row => !row.hasAttribute('hidden')) + return rows.slice(1).map((row, index) => ({ + gap: row.getBoundingClientRect().top - rows[index]!.getBoundingClientRect().bottom, + shrink: getComputedStyle(row).flexShrink, + })) + }) + expect(leafSpacing).toEqual([{ gap: 8, shrink: '0' }, { gap: 8, shrink: '0' }]) + // Expand the individual tool cards so their output exceeds the process viewport. + for (const row of await body.locator('[data-sample="bash"]').all()) await row.click() + await body.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => body.getAttribute('data-scroll-up')).toBeNull() + await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true') + const geometry = await body.evaluate((element) => { + const style = getComputedStyle(element) + element.scrollTop = (element.scrollHeight - element.clientHeight) / 2 + element.dispatchEvent(new Event('scroll')) + return { height: element.clientHeight, max: style.maxHeight, overflow: style.overflowY, + scrollable: element.scrollHeight > element.clientHeight, top: element.scrollTop } + }) + expect(geometry.height).toBeLessThanOrEqual(400) + expect(geometry.overflow).toBe('auto') + expect(geometry.scrollable).toBe(true) + expect(geometry.top).toBeGreaterThan(0) + await expect.poll(() => body.getAttribute('data-scroll-up')).toBe('true') + await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true') + await body.evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll')) + }) + await expect.poll(() => body.getAttribute('data-scroll-down')).toBeNull() + await outer.click() + await outer.click() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + await toggle.click() + const resetGeometry = await body.evaluate(element => ({ height: element.clientHeight, scroll: element.scrollHeight })) + expect(resetGeometry.scroll).toBeLessThanOrEqual(resetGeometry.height) + await toggle.click() + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('chains wheel scrolling from a secondary process range to the conversation', async () => { + const fixture = createChatScrollFixture({ + markerPrefix: 'STEP_PROCESS_SCROLL', title: 'Step process scrolling', turns: 96, + }) + const scaffold = await launchWebScaffold({}) + try { + await seedSession(scaffold, fixture.log, 'step-process-scroll-e2e') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser, 900) + await page.goto(scaffold.authenticatedUrl) + await page.getByText('Ungrouped', { exact: true }).waitFor() + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() + + const outer = page.locator('[data-turn-process="88"]') + await outer.click() + const group = page.locator('[data-step-process][data-chat-turn="88"]').first() + const toggle = group.getByRole('button', { name: 'Ran commands', exact: true }) + await toggle.click() + const body = group.locator('[data-step-process-body]') + const host = page.locator('[data-conversation-scroll]') + const wheelBody = async (deltaY: number): Promise => { + const box = await body.boundingBox() + if (box === null) throw new Error('step process body has no layout box') + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.wheel(0, deltaY) + } + + const shortGeometry = await body.evaluate((element) => { + const scrollHost = element.closest('[data-conversation-scroll]') + if (scrollHost === null) throw new Error('step process body has no conversation scrollport') + return { + hostMax: scrollHost.scrollHeight - scrollHost.clientHeight, + hostTop: scrollHost.scrollTop, + scrollable: element.scrollHeight > element.clientHeight, + } + }) + expect(shortGeometry.scrollable).toBe(false) + expect(shortGeometry.hostTop).toBeGreaterThan(120) + expect(shortGeometry.hostMax - shortGeometry.hostTop).toBeGreaterThan(120) + await wheelBody(-120) + await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(shortGeometry.hostTop) + const shortHostAfterUp = await host.evaluate(element => element.scrollTop) + await wheelBody(120) + await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(shortHostAfterUp) + + for (const row of await body.locator('[data-sample="bash"]').all()) await row.click() + await body.hover() + const middle = await body.evaluate((element) => { + const scrollHost = element.closest('[data-conversation-scroll]') + if (scrollHost === null) throw new Error('step process body has no conversation scrollport') + element.scrollTop = (element.scrollHeight - element.clientHeight) / 2 + element.dispatchEvent(new Event('scroll')) + return { + bodyTop: element.scrollTop, + hostTop: scrollHost.scrollTop, + scrollable: element.scrollHeight > element.clientHeight, + } + }) + expect(middle.scrollable).toBe(true) + await wheelBody(80) + await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(middle.bodyTop) + expect(await host.evaluate(element => element.scrollTop)).toBe(middle.hostTop) + + await body.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new Event('scroll')) + }) + await body.hover() + const hostBeforeTopChain = await host.evaluate(element => element.scrollTop) + await wheelBody(-120) + await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(hostBeforeTopChain) + expect(await body.evaluate(element => element.scrollTop)).toBe(0) + + await body.evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll')) + }) + await body.hover() + const bottomEdge = await body.evaluate((element) => { + const scrollHost = element.closest('[data-conversation-scroll]') + if (scrollHost === null) throw new Error('step process body has no conversation scrollport') + return { + hostTop: scrollHost.scrollTop, + maxBodyTop: element.scrollHeight - element.clientHeight, + } + }) + await wheelBody(120) + await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(bottomEdge.hostTop) + expect(await body.evaluate(element => element.scrollTop)).toBe(bottomEdge.maxBodyTop) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) + +it('keeps a waking notice above and independent of the turn disclosure', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'TURN_TRIGGER', title: 'Background task notice', turns: 1, + wakingInput: { source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'PR merge check completed' }, + content: 'background job bash-39 (bash: PR merge check) finished [status: completed, exit code 0]. Read its output with job_output.' } }) + const scaffold = await launchWebScaffold({}) + try { + const log = fixture.log.trimEnd().split('\n').map((line, index) => index === 0 ? line + : JSON.stringify({ ...JSON.parse(line) as Record, time: 1_800_000_000_000 })).join('\n') + '\n' + await seedSession(scaffold, log, 'turn-trigger-e2e') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser, 900) + await page.goto(scaffold.authenticatedUrl) + await page.getByText('Ungrouped', { exact: true }).waitFor() + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill('PR merge check') + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() + const notice = page.locator('[data-turn-trigger]') + const button = notice.getByRole('button') + const outer = page.locator('[data-turn-process="1"]') + await notice.waitFor() + expect(await button.getAttribute('aria-expanded')).toBe('false') + expect(await button.textContent()).toContain('Background task updated') + const noticeBox = await notice.boundingBox() + const outerBox = await outer.boundingBox() + expect(noticeBox!.y + noticeBox!.height).toBeLessThan(outerBox!.y) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/trigger-collapsed.md', import.meta.url)), + (await notice.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode()) + await button.click() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/trigger-expanded.md', import.meta.url)), + (await notice.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode()) + expect(await outer.isEnabled()).toBe(false) + expect(await outer.getAttribute('aria-expanded')).toBeNull() + expect(await outer.locator('svg').count()).toBe(0) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/empty-turn.md', import.meta.url)), + await outer.ariaSnapshot(), webSnapshotMode()) + expect(await button.getAttribute('aria-expanded')).toBe('true') + expect(await notice.isVisible()).toBe(true) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) + + +it('places hover-only actions below trailing reasoning in a stopped turn', async () => { + const fixture = createChatScrollFixture({ markerPrefix: 'STOPPED_FOOTER', title: 'Stopped turn footer', turns: 1, + stopAfterReasoning: true }) + const scaffold = await launchWebScaffold({}) + try { + await seedSession(scaffold, fixture.log, 'stopped-footer-e2e') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser, 900) + await page.goto(scaffold.authenticatedUrl) + await page.getByText('Ungrouped', { exact: true }).waitFor() + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1) + await results.click() + const tail = page.locator('[data-turn-tail="1"]') + await tail.waitFor() + expect(await tail.getAttribute('data-actions-reveal')).toBe('hover') + const lastGroup = page.locator('[data-step-process]').last() + await lastGroup.getByRole('button', { name: 'Analysis completed', exact: true }).click() + const think = lastGroup.getByRole('button', { name: 'Think', exact: true }) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/think-collapsed.md', import.meta.url)), + await think.ariaSnapshot(), webSnapshotMode()) + expect(await lastGroup.getByText('Inspect the remaining work before continuing.', { exact: true }).isVisible()).toBe(false) + await think.click() + expect(await lastGroup.getByText('Inspect the remaining work before continuing.', { exact: true }).isVisible()).toBe(true) + await think.click() + const groupBox = await lastGroup.boundingBox() + const tailBox = await tail.boundingBox() + expect(tailBox!.y).toBeGreaterThanOrEqual(groupBox!.y + groupBox!.height) + const copy = tail.getByRole('button', { name: 'Copy', exact: true }) + const actions = copy.locator('..') + await page.mouse.move(0, 0) + await expect.poll(() => actions.evaluate(element => getComputedStyle(element).opacity)).toBe('0') + await tail.hover() + await expect.poll(() => actions.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/stopped-footer.md', import.meta.url)), + (await tail.ariaSnapshot()).replace(/\d\d:\d\d/g, 'HH:mm'), webSnapshotMode()) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index fc29e7f913..6cde4d13f3 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -64,21 +64,26 @@ export async function expandTurnProcesses(page: Page): Promise { const count = await controls.count() for (let index = 0; index < count; index++) { const control = controls.nth(index) - if (await control.getAttribute('aria-expanded') !== 'true') await control.click() + if (await control.isEnabled() && await control.getAttribute('aria-expanded') !== 'true') await control.click() } } /** - * Expand the Turn-process group containing one possibly hidden descendant. + * Expand the Turn and secondary process groups containing a possibly hidden descendant. * @param page - page containing the Chat view. - * @param target - descendant whose owning Turn process should open. + * @param target - DOM locator matching the descendant even while its process groups are hidden. */ export async function expandOwningTurnProcess(page: Page, target: Locator): Promise { + await target.waitFor({ state: 'attached', timeout: 10_000 }) const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn) - if (turn === undefined || await target.isVisible()) return + if (turn === undefined) return const control = page.locator(`[data-turn-process="${turn}"]`) - await control.waitFor({ state: 'visible', timeout: 10_000 }) - if (await control.getAttribute('aria-expanded') !== 'true') await control.click() + if (await control.count() > 0 && await control.isEnabled() + && await control.getAttribute('aria-expanded') !== 'true') await control.click() + const group = target.locator('xpath=ancestor::*[@data-step-process][1]') + if (await group.count() === 0) return + const stepControl = group.locator(':scope > button').first() + if (await stepControl.getAttribute('aria-expanded') !== 'true') await stepControl.click() } /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ diff --git a/apps/web/tests/thinking-markdown.e2e.ts b/apps/web/tests/thinking-markdown.e2e.ts index 131f3dce24..83bfa94d75 100644 --- a/apps/web/tests/thinking-markdown.e2e.ts +++ b/apps/web/tests/thinking-markdown.e2e.ts @@ -16,7 +16,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url)) @@ -112,6 +112,7 @@ describe('web e2e: secondary Thinking Markdown', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: 'detailed' }) await seedSession(scaffold, thinkingFixture(), SEED_ID) browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser) @@ -138,6 +139,7 @@ describe('web e2e: secondary Thinking Markdown', () => { it.skipIf(MODE === 'record')('renders semantic blocks without promoting headings or overflowing the column', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-thinking-markdown')) const thinking = page.locator('[data-variant="think"]') + await expandOwningTurnProcess(page, thinking) const toggle = thinking.getByRole('button').first() const summary = thinking.locator('[class*="summaryText"]') const summaryStyle = await summary.evaluate((element) => { @@ -226,13 +228,13 @@ describe('web e2e: secondary Thinking Markdown', () => { .evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize)) expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize)) await page.setViewportSize({ width: 1680, height: 1000 }) - await page.locator('[data-conversation-scroll]').evaluate((host) => { + await page.locator('[data-step-process-body]').evaluate((host) => { const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)') if (row === null) throw new Error('tall Thinking table row missing') host.scrollTop += row.getBoundingClientRect().top - host.getBoundingClientRect().top }) await expect.poll(() => toggle.evaluate((button) => { - const host = button.closest('[data-conversation-scroll]') + const host = button.closest('[data-step-process-body]') const table = button.closest('[data-variant="think"]')?.querySelector('table') if (host === null || table === null || table === undefined) throw new Error('Thinking scroll context missing') const buttonRect = button.getBoundingClientRect() diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index a1a84780e9..20e1fd2ca5 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -69,6 +69,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd))) const snapshot = snapshots.join('\n') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() await page.setViewportSize({ width: 360, height: 800 }) const card = page.locator('[data-tool="schedule_create"]') expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7ea13e1c9c..577602717d 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -135,11 +135,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { // The marker IS the synchronization: the second call is provably parked, // so the first step's message and tool result are already durable. await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true) - expect(await page.locator('[data-turn-process]').count()).toBe(0) - await expect.poll( - () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), - { timeout: 10_000 }, - ).toBe(true) + const liveProcess = page.locator('[data-turn-process]') + expect(await liveProcess.count()).toBe(1) + expect(await liveProcess.textContent()).toMatch(/^Deep diving for /) + expect(await page.getByRole('status').filter({ hasText: 'Deep diving...' }).count()).toBe(1) await page.locator('[data-streaming="true"]') .getByText('partial', { exact: true }) .waitFor({ timeout: 10_000 }) @@ -232,7 +231,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) - it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => { + it.skipIf(MODE === 'record')('switches a completed Turn between Compact, Detailed, and Expanded', async () => { await launch() onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) const { settled } = await sendPrompt() @@ -245,22 +244,30 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) - await dialog.getByRole('button', { name: 'Compact', exact: true }).click() - await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + const workDetailsRow = dialog.getByText('Work details', { exact: true }).locator('xpath=../..') + await workDetailsRow.getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click() + await page.keyboard.press('Escape') + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + expect(await tool.isVisible()).toBe(false) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await workDetailsRow.getByRole('button', { name: 'Detailed', exact: true }).click() + await page.getByRole('menuitem', { name: 'Expanded', exact: true }).click() await page.keyboard.press('Escape') - await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1) + await process.click() await tool.waitFor({ state: 'visible', timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toMatch(/ui-chat:\n\s+transcriptView: expanded/) await page.getByRole('button', { name: 'Settings', exact: true }).click() const restored = page.getByRole('dialog', { name: 'Settings' }) - await restored.getByRole('button', { name: 'Normal', exact: true }).click() + await restored.getByRole('button', { name: 'Expanded', exact: true }).click() await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() await page.keyboard.press('Escape') await process.waitFor({ timeout: 10_000 }) - expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await process.getAttribute('aria-expanded')).toBe('true') expect(await tool.isVisible()).toBe(false) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -270,6 +277,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await launch(undefined, 200) onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused')) const { settled } = await sendPrompt() + const stepProcess = page.locator('[data-step-process] > button[data-process-activity="commands"]') + await stepProcess.click() const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' }) await tool.waitFor({ timeout: 30_000 }) await tool.focus() diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index fff5eab5d4..3ecf8ec28f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -15,7 +15,7 @@ import { type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, + connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot, } from './support.ts' const MODE = webSnapshotMode() @@ -90,6 +90,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await input.press('Enter') const workflow = page.locator('[data-workflow-run][data-run-status="running"]') + await expandOwningTurnProcess(page, workflow) await workflow.waitFor({ timeout: 30_000 }) const disclosures = workflow.locator('[data-disclosure-row]') await disclosures.nth(1).waitFor({ timeout: 15_000 }) @@ -178,6 +179,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = releaseChild.resolve(undefined) await settled await expandTurnProcesses(page) + await expandOwningTurnProcess(page, page.locator('[data-workflow-run][data-run-status="completed"]')) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) @@ -204,6 +206,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expandTurnProcesses(page) + await expandOwningTurnProcess(page, page.locator('[data-workflow-run][data-run-status="completed"]')) const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) await workflow.waitFor({ timeout: 15_000 }) expect(await workflow.getAttribute('aria-expanded')).toBe('false') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 56fe6bf139..824849473a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -139,6 +139,7 @@ "tests/goal-multi-turn-actions.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/chat-scroll-contract.e2e.ts", + "tests/step-process.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts index 707051a4dc..8820d32fdb 100644 --- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts @@ -18,7 +18,7 @@ import { chatViewDefinition } from '../../packages/client/ui-chat/src/client/con import { commandDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/command.ts' import { compactionDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/compaction.ts' import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts' -import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts' +import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts' import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts' import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts' @@ -45,6 +45,7 @@ export interface ConversationFoldWorkerReport { class BenchEventDefinitions { readonly definitions: readonly ConversationNodeDefinition[] = [ nextStepInboxDefinition, + nextTurnInboxDefinition, messageDefinition, requestPromptDefinition(inspectRequestPrompt), assistantDefinition, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index d8e7c6416f..123e3d12d6 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc -README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d +README.md: e892d95b8a5e53bc71304d012ee81eb4b606ef1d +README.zh.md: 6704a6e54738633f27062a0f7ec65c83bdb81d05 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 7f3d905b02..e892d95b8a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -15,7 +15,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Table of Contents - [Reference previews](#reference-previews) -- [System prompt row](#system-prompt-row) +- [System prompt visibility](#system-prompt-row) - [Command and failure rows](#command-and-failure-rows) - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) @@ -35,9 +35,9 @@ Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. -## System prompt row +## System prompt visibility -Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. +System prompts have no Chat row in any Work details mode. Their durable records remain available in trajectory inspection; Chat filtering does not change model input. ## Command and failure rows @@ -58,16 +58,18 @@ On non-loopback browsers, the preference remains process-local because the setti ## Completed-turn footer -The completed-turn action footer starts 20px below the preceding prose or extension content. +The completed-turn action footer sits at the bottom of its Turn, 20px below the preceding prose or extension content. Only the latest Turn ending in response content keeps its actions visible; trailing reasoning, tools, notices, or other content makes them appear on hover or keyboard focus, like historical Turns. Devices without hover keep actions visible. Feedback and copy still address the last text response. ----- +The Chat snapshot builder owns stable step-group layout, cached activity summaries, and Turn-footer eligibility. Body-only stream updates refresh the affected groups without scanning historical groups; renderer-seat changes and pagination rebuild layout while retaining unchanged group identities. Unknown Turn status does not mark a trailing group complete. Whole-Turn lifecycle changes announce through a polite status region; clock ticks do not repeat that announcement. + ## Turn Process Folding -Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. The collapsed summary stays on one line, follows the latest reasoning line while streaming, and shows the first line after settlement. +Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. During streaming, the collapsed preview advances only after a paragraph's first line completes; an unfinished first line does not replace the current preview. The preview starts at its left edge and fades under a right-edge mask when it exceeds the available width. After settlement, Compact shows only `Think`, while Detailed and Expanded preview the first line. -Settings → General exposes a persisted, localized `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. +Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. All three modes retain the whole-Turn disclosure and default completed Turns to collapsed. Detailed defaults step groups to collapsed and allows manual toggles. Expanded renders steps directly inside the whole-Turn disclosure, without secondary titles, disclosure controls, or bounded group scroll areas. Each secondary title starts with its matching existing Tool icon; the command category uses Bash's `IconApiOutlineRegular`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, with no trailing chevron. Hovering an enabled whole-Turn or secondary disclosure title darkens its text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages; an expanded title sits 16px above its first process row, and adjacent process rows are 8px apart. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `detailed` without rewriting storage; explicit choices save only the three current values. System prompts, ordinary context injections, and permission commands have no Chat row in any Work details mode. Non-human turn-trigger notices remain visible above the whole-Turn control; hidden metadata does not create process groups or collapse arrows. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity, and carries a repeating ease-in-out text shimmer with a 500ms pause after each pass until the process range closes. Each displayed live title remains for at least 150ms; changes during that interval coalesce to the latest title instead of queuing intermediate tools. Detailed places a concise current-call detail to the right of the category, preferring an authored title, description, objective, task, question, command, query, URL, or path and falling back to the exact tool name. While no tool is running, it shows the latest live reasoning paragraph instead. Details are whitespace-normalized and capped at 160 characters; completed titles omit them. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Secondary expanded ranges scroll within the smaller of 400px and half the viewport height; a 24px fade marks each direction with hidden overflow and disappears independently at the corresponding scroll edge. Wheel and touchpad scrolling stays with the range while it can move, then continues the conversation when the range is too short to scroll or reaches either edge. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Deep diving for …` (Chinese `深度求索中,用时…`) with a minimum of 1s and no leading icon. Running, completed, stopped, and failed whole-Turn labels all use tertiary text at one pixel below the normal status size. The live clock refreshes once per second, shows whole seconds without padding single-digit seconds, and changes from minutes to hours at exactly 60 minutes. Compact, Detailed, and Expanded use this whole-Turn control as the only turn-level running label. Normal completion fixes the duration at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Running, cancelled, and failed Turns stay expanded with a disabled control and no chevron. Missing start timestamps omit the duration. A Turn with no process rows or inline reasoning keeps the elapsed label but hides its chevron and disables disclosure. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer folds all process rows while keeping independent rows visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection; System prompt rows are not rendered. While older history remains available through Load earlier, closed Turns whose `turn/start` is loaded can fold independently, including Turns awakened by context injection. A Turn missing its start waits until pagination supplies it or exhausts history; unrelated unloaded Turns do not block folding. Stable Chat Node Seats preserve row placement, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. Closing a whole Turn resets its nested groups and tool details to collapsed; hidden process renderers remount while their seats stay stable. The session-scoped store records expanded completed answer generations; different answer generations start collapsed. ----- @@ -117,3 +119,5 @@ None. **Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency. + +Non-human input claimed from the next-turn Inbox appears as an independent trigger notice above the Turn control. A first-step next-step claim also qualifies when that Turn has no next-turn claim or human input in its next-step batch. Ordinary pre-step injections are hidden in every Work details mode; their durable records remain available in trajectory inspection. The compact outlined header shows a source-family icon, title, and subdued time without repeating a subject, and the whole notice highlights on hover. The notice stays visible in every work-details mode, defaults to collapsed, and expands independently of process folding to show a short explanation and the original model-facing content without source metadata. Titles and icons use durable source-family fields for schedules, jobs, goals, agent/team messages, webhooks, and Cordis runner notices; the UI never infers outcomes from producer-authored text, so the expanded original content remains the outcome authority. Task messages use the paper-plane icon, other recognized source families use their matching primitive icons, and unknown sources retain the context-injection fallback. User-attributed SDK and delegated inputs remain user messages because the log does not distinguish their caller. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 118fc29fdb..6704a6e547 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -15,7 +15,7 @@ kind: "package-reference" ## 目录 - [引用预览](#reference-previews) -- [系统提示词行](#system-prompt-row) +- [系统提示词可见性](#system-prompt-row) - [指令与失败行](#command-and-failure-rows) - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) @@ -35,9 +35,9 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 -## 系统提示词行 +## 系统提示词可见性 -每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 +所有工作过程展示模式均不显示系统提示词行。其持久化记录仍可在轨迹中查看;Chat 过滤不改变模型输入。 ## 指令与失败行 @@ -58,16 +58,18 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 ## 已完成轮次的页脚 -已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。 +已完成轮次的操作页脚固定在本轮底部,距前方正文或扩展内容 20px。仅最后一轮以回复正文结尾时常显按钮;末尾为推理、工具、提示或其他内容时,与历史轮次一样通过悬停或键盘聚焦显示。不支持悬停的设备始终显示按钮。反馈与复制仍针对最后一条文字回复。 ----- +Chat 快照构建器维护稳定的步骤分组布局、缓存的活动摘要和轮次页脚展示条件。仅正文变化的流式更新只刷新受影响的分组,不扫描历史分组;渲染位置变化和分页会重建布局,同时保留未变化分组的引用。未知的轮次状态不会把末尾分组标记为完成。整轮生命周期变化通过礼貌播报区域通知读屏器,时钟跳动不会重复播报。 + ## 轮次过程折叠 -每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。 +每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式生成期间,收起预览仅在某段第一行结束后切换;尚未结束的第一行不会替换当前预览。预览从左侧起始,超出可用宽度的内容在右缘渐隐。结算后,简洁模式只显示「思考」,详细和完全展开模式显示首行预览。 -「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「标准」/「紧凑」对话显示偏好,默认使用「紧凑」。标准模式保持所有过程行可见且不渲染轮次过程控件。紧凑模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。 +「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。三种模式均保留一级整轮折叠,已完成轮次默认收起。详细默认收起步骤组,允许手动切换;完全展开在一级折叠内直接平铺步骤,不显示二级标题、折叠控件或限高的组内滚动区。每个二级标题默认以前置的现有工具图标表示类别,其中命令类别使用 Bash 的 `IconApiOutlineRegular`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不再显示箭头。悬停可用的整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px;展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `detailed`,不主动改写存储;用户选择后仅保存三个新值。所有工作过程展示模式均不显示系统提示词、普通上下文注入和 permission 命令行。非人工轮次发起提示仍独立显示在一级折叠上方;隐藏的元数据不产生过程组或折叠箭头。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段,并在过程组关闭前循环显示采用 ease-in-out 曲线、每轮结束后停顿 500ms 的文字扫光。每个已显示的进行中标题至少保留 150ms;这段时间内的变化只保留最新标题,不排队补播中间工具。详细模式在类别右侧显示简短的当前调用详情,优先采用调用方给出的标题、描述、目标、任务、问题、命令、查询词、URL 或路径,没有可用详情时回退到原始工具名;没有工具运行时改为显示最新的实时思考段落。详情会合并空白并截断到 160 个字符;完成标题不保留进行中详情。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。二级展开区在 400px 与视口高度的一半之中取较小值作为最大高度;存在隐藏内容的方向显示 24px 渐隐遮罩,到达对应滚动边缘时该侧遮罩消失。滚轮和触控板滚动会在区内仍可滚动时留在该区域;区域无需滚动或到达任一边界后,滚动继续作用于整个对话。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题文案为「深度求索中,用时…」(英文为 `Deep diving for …`),最少显示 1 秒且不带前置图标。运行、完成、停止和失败的整轮状态文案均使用 tertiary 灰度,字号比普通状态小 1px。运行时钟每秒更新,显示整数秒且个位秒不补零,满 60 分钟切换为小时。简洁、详细和完全展开均只使用该整轮控件显示轮次运行状态。正常完成后固定时长,最少显示 1 秒,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。进行中、取消或失败的轮次始终展开,控件不可点击且不显示箭头。缺少起点时间戳时省略时长。没有过程行或内联推理的轮次保留用时标题,但隐藏箭头并禁用折叠。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词行不显示。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 保持行的位置,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。收起整轮会把内部过程组和工具详情重置为折叠;隐藏的过程 renderer 重新挂载,seat 保持稳定。会话作用域存储记录完成后的手动展开正文 generation;不同正文 generation 均默认收起。 ----- @@ -117,3 +119,5 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有 **运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。 + +从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。所有工作过程展示模式均隐藏普通 pre-step 注入,其持久化记录仍可在轨迹中查看。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 通知的标题和图标仅使用持久化的来源类别字段;UI 不从生产方正文推断结果,展开后的原始内容仍是结果信息的依据。任务消息使用纸飞机图标,其他已识别来源使用对应的 primitive 图标,未知来源使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。 diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index dbf9014320..fe6a445563 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -5,13 +5,13 @@ import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the Chat target. */ export const CHAT_SETTINGS_NAMESPACE = 'ui-chat' -/** Field carrying the completed-Turn transcript presentation mode. */ +/** Field carrying the work details presentation mode. */ export const TRANSCRIPT_VIEW_FIELD = 'transcriptView' /** Transcript presentation modes accepted at settings boundaries. */ -export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const +export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const -/** Completed-Turn transcript presentation. */ +/** Work details presentation. */ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number] /** Default preserves the compact process disclosure introduced by Chat. */ @@ -28,8 +28,8 @@ export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed' /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { - /** Presentation mode for completed Turn process content. */ - transcriptView: TranscriptViewMode + /** Work details preference; normal is accepted only for existing saved settings. */ + transcriptView: TranscriptViewMode | 'normal' /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode } @@ -37,5 +37,5 @@ export interface ChatSettings { /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsSchema: z = z.object({ performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), - [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), + [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES, 'normal']).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 13ff7fae4b..f27a3d721a 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -23,6 +23,9 @@ export function localPathMediaUrl(base: string, value: string): string | undefin } export interface AssistantMarkdownProps { + /** Hide settled reasoning previews in Compact work details. */ + compactTranscript?: boolean + blocks: readonly AssistantBlock[] streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ @@ -41,7 +44,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, + compactTranscript = true, blocks, streaming, interrupted, renderMessageImages, reasoningHidden = false, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object @@ -83,7 +86,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 399897a9b5..732cf6620d 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, + compactTranscript = true, node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -28,6 +28,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess]) return ( { + const value = sourceNode as ChatNode | undefined + if (value?.kind !== 'assistant-step' || assistantPart === undefined) return sourceNode + return { ...value, data: { ...value.data, blocks: value.data.blocks.filter(block => + assistantPart === 'reasoning' ? block.kind === 'reasoning' : block.kind !== 'reasoning'), + } } + }, [sourceNode, assistantPart]) const routedNode = node as ChatNode | undefined const turn = turnOf(routedNode) const processPresentation = useChatNodeProcess(nodeKey) @@ -49,45 +57,44 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ ? undefined : storedTurnProcessEntry(state, processSpec.turn)) const processEntry = processSpec !== undefined - && processSpec.answerStep !== null - && storedEntry?.answerStep === processSpec.answerStep + && storedEntry?.answerStep === (processSpec.answerStep ?? 0) ? storedEntry : undefined - const processOpen = processEntry !== undefined + const liveProcess = processPresentation !== undefined && !processPresentation.turnClosed + const alwaysOpen = liveProcess || turnProcessAlwaysOpen(routedNode) + const processOpen = alwaysOpen || processEntry !== undefined const setOpen = useCallback((open: boolean) => { - if (processSpec !== undefined && processSpec.answerStep !== null) { - actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open) + if (processSpec !== undefined && !alwaysOpen) { + actions.setTurnProcessOpen(processSpec.turn, (processSpec.answerStep ?? 0), open) } - }, [actions, processSpec]) + }, [actions, processSpec, alwaysOpen]) const processWindowReady = processSpec !== undefined && processPresentation !== undefined - && compactTranscript - && processSpec.answerAnchorSeq !== null && processPresentation.turn === processSpec.turn - && processPresentation.turnClosed - && !historyIncomplete + && (!historyIncomplete || processPresentation.turnStarted) const processMember = routedNode !== undefined && processWindowReady && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) && routedNode.anchorSeq >= processSpec.processStartSeq - && routedNode.anchorSeq < processSpec.answerAnchorSeq + && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq) const processAnswer = routedNode !== undefined && processWindowReady + && !liveProcess && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer const foldable = processWindowReady - && (processMember || (ownsDisclosure - && (processPresentation.hasExternalProcess || processSpec.inlineReasoning))) + && (liveProcess || processMember || ownsDisclosure) const turnProcess = useMemo(() => processSpec === undefined ? undefined : { spec: processSpec, foldable, + hasContent: processPresentation?.hasExternalProcess === true || processSpec.inlineReasoning, open: processOpen, setOpen, }, [ - foldable, processOpen, processSpec, setOpen, + foldable, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen, ]) const controllerInactive = routedNode?.kind === 'turn-process' && !foldable @@ -100,6 +107,12 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ if (processMember) setOpen(true) }, [processMember, setOpen]) const wrapperRef = useSearchableHidden(processHidden, revealProcess) + const [disclosureReset, setDisclosureReset] = useState(0) + useEffect(() => { + if (processMember && processHidden && wrapperRef.current?.hasAttribute('hidden')) { + setDisclosureReset(value => value + 1) + } + }, [processMember, processHidden, wrapperRef]) const owner = useMemo(() => node === undefined ? null : { @@ -112,9 +125,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ renderMessageImages, fileMentions, turnProcess, + compactTranscript, }, [ node, cwd, openFile, openSkill, inspectCall, forkAt, - loadImage, renderMessageImages, fileMentions, turnProcess, + loadImage, renderMessageImages, fileMentions, turnProcess, compactTranscript, ]) if (routedNode === undefined || owner === null) return null const turnData = turnDataOf(routedNode) @@ -126,7 +140,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
- {renderSlot('conversation.chat.node', routedOwner, { - entryKey: routedNode.kind, - hookContext: turnData, - fallback: ( - t('json.truncated', { total })} - /> - ), - })} + + {renderSlot('conversation.chat.node', routedOwner, { + entryKey: routedNode.kind, + hookContext: turnData, + fallback: ( + t('json.truncated', { total })} + /> + ), + })} +
) }) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 0b1487cefb..572a6340c8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -46,11 +46,31 @@ /* `hidden="until-found"` retains a zero-height box so browser find can reveal its subtree. Direct Chat Node Seats and auxiliary rows share one flow; hidden and empty Seats do not contribute spacing. */ -.column > :not([hidden]):not(.flowItem:empty) +:is(.column, .processBody) > :not([hidden]):not(.flowItem:empty) ~ :not([hidden]):not(.flowItem:empty) { margin-top: var(--dsh-chat-flow-gap, 16px); } +.column > [data-step-process]:not([hidden]) + + [data-chat-flow-kind='assistant-step']:not([hidden]) { + --dsh-chat-flow-gap: 16px; +} + +.column > [data-chat-flow-kind='assistant-step']:not([hidden]) + + [data-step-process]:not([hidden]) { + --dsh-chat-flow-gap: 16px; +} + +/* Capped process scrollports retain the same row spacing without shrinking leaves. */ +.processBody { + display: flex; + flex-direction: column; +} + +.processBody > * { + flex-shrink: 0; +} + /* Settled-flow identity boundary. It is neutral until it becomes the natural measurement/mount unit for a virtualizer without changing the column gap. */ .flowItem { @@ -77,65 +97,6 @@ /* Selection still sets data-selected for details linkage; no outline — tool rows match Think chrome (no selected ring). */ -/* Turn activity keeps the former loader's one-line footprint. A pale - brand-blue band sweeps from left to right; reduced-motion keeps it static. */ -.turnStatus { - align-self: flex-start; - flex: none; - display: inline-flex; - align-items: center; - height: calc(26px + var(--dsh-content-font-delta, 0px)); - font: var(--dsw-font-s-strong-14); - /* The status line reads at the body size: the fixed 14 token supplies the - family/weight, the axis supplies the size. At default that is 14px text - on a 22px line inside a 26px row; both box heights shift by the same - delta, preserving the 8px (22 − 14) leading and the 4px (26 − 22) row - padding at every size. */ - font-size: var(--dsh-content-font-size, 14px); - line-height: calc(22px + var(--dsh-content-font-delta, 0px)); - white-space: nowrap; - background: linear-gradient( - 90deg, - var(--dsw-static-deepseek-500) 0%, - var(--dsw-static-deepseek-500) 40%, - var(--dsw-static-deepseek-200) 50%, - var(--dsw-static-deepseek-500) 60%, - var(--dsw-static-deepseek-500) 100% - ); - background-position: 100% 0; - background-size: 250% 100%; - background-clip: text; - color: transparent; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: dsh-turn-status-shimmer 1.8s linear infinite; -} - -.turnStatusClock { - margin-left: 8px; - font: var(--dsw-font-xs-13); - font-size: var(--dsh-content-font-size-secondary, 13px); - line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); - font-weight: 400; - font-variant-numeric: tabular-nums; - color: var(--dsw-alias-label-caption); - -webkit-text-fill-color: var(--dsw-alias-label-caption); -} - -@keyframes dsh-turn-status-shimmer { - to { - background-position: 0 0; - } -} - -@media (prefers-reduced-motion: reduce) { - .turnStatus { - background-position: 0 0; - background-size: 100% 100%; - animation: none; - } -} - .hint { color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 83facf3576..fb63d9e994 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -1,10 +1,8 @@ // An enclosing `[data-conversation-scroll]` owns scrolling when present; // otherwise this view owns it. Each row subscribes to one stable node key. -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react' -import type { - ConversationTimelineSnapshot, RenderMessageImages, -} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { useCallback, useMemo, useRef, useState } from 'react' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, @@ -12,11 +10,10 @@ import { import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' -import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { ChatNodeList } from './StepProcessList.tsx' import { TurnNavigator } from './TurnNavigator.tsx' import { mergeTurnRailItems } from './turn-rail-items.ts' import { useChatScroll } from './use-chat-scroll.ts' -import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' /** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ @@ -52,60 +49,6 @@ function observedRpcIds( return observed } -function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { - let latest: number | null = null - for (const turn of timeline.turns.values()) { - if (turn.status === 'open') latest = turn.start?.time ?? null - } - return latest -} - -/** Turn-level model activity label retained across first-token, tool, and streaming phases. */ -function TurnStatus({ startTime, t }: { - /** The running turn's logged `turn/start` time; null falls back to mount - * time when that boundary is outside the window. */ - startTime: number | null - /** The owning view's locale seat. */ - t: ChatViewSlotProps['t'] -}) { - const [mountedAt] = useState(() => Date.now()) - // Anchored to turn/start so a mid-turn reload keeps the real - // elapsed time and the final footer's Ran-for label matches this clock. - const anchor = startTime ?? mountedAt - const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor)) - useEffect(() => { - const tick = (): void => { - setElapsedMs(Math.max(0, Date.now() - anchor)) - } - tick() - const id = setInterval(tick, 1000) - return () => { clearInterval(id) } - }, [anchor]) - // Short turns keep the plain label; the clock only appears once the turn - // has clearly been running for a while. - const showClock = elapsedMs >= 15_000 - return ( -
- {t('chat.deepDiving')} - {showClock && ( - - {formatRunDuration(elapsedMs, t)} - - )} -
- ) -} - -type ChatNodeListProps = Omit, 'nodeKey'> & { - readonly order: readonly string[] -} - -const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) { - return order.map(nodeKey => ( - - )) -}) - /** * The chat view slot entry: pure component over the composed props; each * ordered business Node crosses the keyed renderer seat. @@ -128,7 +71,6 @@ export function ChatView({ () => mergeTurnRailItems(turnNavigationItems, turnOutline), [turnNavigationItems, turnOutline], ) - const timeline = useChat(s => s.timeline) const inbox = useProjection('inbox') as unknown as InboxState | undefined // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -137,7 +79,8 @@ export function ChatView({ const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) - const compactTranscript = useTranscriptView(mode => mode === 'compact') + const transcriptMode = useTranscriptView(mode => mode) + const compactTranscript = transcriptMode === 'compact' const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) // Close/retry must ignore a settlement that started before the latest @@ -192,7 +135,6 @@ export function ChatView({ owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], ) - const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const firstKey = order[0] const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null @@ -234,11 +176,12 @@ export function ChatView({ )} } {pendingSteering.map(item => ( | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : null -} - /** One run of the model-facing content: adjacent text, or one unknown block. */ type ContentRun = { text: string } | { block: unknown } -/** - * The content blocks as runs, IN THE ORDER the model received them. - * - * Adjacent text blocks join with no separator, matching how provider adapters - * flatten them — inserting a line break would show the reader a line the model - * never saw. An unknown block breaks the run and keeps its own fallback rather - * than being hoisted past the text around it or vanishing; the block union is - * merge-extensible, so a foreign log may interleave shapes this build does not - * know. - */ +/** Join adjacent text while preserving unknown blocks in their original order. */ function contentRuns(content: ContextMessageNode['content']): ContentRun[] { const runs: ContentRun[] = [] for (const block of content) { @@ -53,11 +28,6 @@ function contentRuns(content: ContextMessageNode['content']): ContentRun[] { return runs } -/** Only the blocks this UI version does not know, for bodies that replace the text. */ -function unknownBlocks(content: ContextMessageNode['content']): unknown[] { - return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : []) -} - /** The model-facing text, truncated to the display bound. */ function boundedText(text: string, t: Translate): string { return text.length > MAX_CHARS @@ -66,77 +36,11 @@ function boundedText(text: string, t: Translate): string { } /** - * One source field rendered as a value row; nested shapes stay compact JSON. - * Bounded on its own, because source fields are as unbounded as the text: an unknown - * producer may record an arbitrarily large string or array. + * Render the recorded content of a Turn trigger, including unknown blocks. + * @param props - durable content and the locale seat. + * @returns bounded text and JSON fallbacks in their original order. */ -function fieldValue(value: unknown, t: Translate): string { - const text = typeof value === 'string' - ? value - : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value) - return boundedText(text, t) -} - -/** - * Source fields as a key/value list. `kind` is always omitted because the - * row header already names the producer. `form` is omitted only when a - * dedicated body rendered for it — then the presentation the reader is looking - * at IS that value. On the opaque fallback the declaration is kept, because - * that is the one place a form this version cannot present would otherwise - * disappear from the UI entirely. - */ -function SourceFields({ source, formRendered, t }: { - source: unknown - formRendered: boolean - t: Translate -}): ReactNode { - const record = asRecord(source) - if (record === null) return null - const hidden = formRendered ? ['kind', 'form'] : ['kind'] - const rows = Object.entries(record).filter(([key]) => !hidden.includes(key)) - if (rows.length === 0) return null - return ( -
- {rows.map(([key, value]) => ( -
-
{key}
-
{fieldValue(value, t)}
-
- ))} -
- ) -} - -/** - * Content blocks this UI version does not know, kept visible rather than - * dropped: the block union is merge-extensible, so a newer or foreign log may - * carry a shape this build has no presentation for. - * @param props - The unrecognized blocks and the locale seat. - * @returns One generic JSON block per unknown entry. - */ -function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode { - return ( - <> - {blocks.map((block, index) => ( - t('json.truncated', { total })} - /> - ))} - - ) -} - -/** - * The model-facing content of one context, shared by every form that shows it: - * the text with its real line breaks, then any block this UI version does not - * know, which keeps its own fallback rather than vanishing. - * @param props - Durable content and the locale seat. - * @returns The content blocks as the model received them. - */ -function ModelFacingContent({ content, t }: { +export function ContextContentBody({ content, t }: { content: ContextMessageNode['content'] t: Translate }): ReactNode { @@ -157,436 +61,3 @@ function ModelFacingContent({ content, t }: { ) } - -/** - * Default presentation: the model-facing text as text, with its real line - * breaks, and the remaining source fields beneath it. This is what every form - * this UI version does not recognize renders as. - * @param props - Durable content, its source, and the locale seat. - * @returns The opaque context body. - */ -export function OpaqueBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - return ( - <> - - - - ) -} - -/** One reconciled instruction file, as the durable source records it. */ -interface InstructionChange { - action: 'set' | 'replace' | 'remove' - path: string - digest?: string -} - -/** - * Instruction changes read off the source, or null when the record is not a - * usable instruction list. - * - * The read is all-or-nothing: silently dropping one unreadable entry would show - * a confident, incomplete file list for a log this version cannot fully read. - * Paths are deduplicated in first-seen order, matching how the header label is - * derived from the same array. - */ -function instructionChanges(source: unknown): InstructionChange[] | null { - const record = asRecord(source) - const list = record === null ? undefined : record['changes'] - if (!Array.isArray(list)) return null - const changes: InstructionChange[] = [] - const seen = new Set() - for (const entry of list as readonly unknown[]) { - const change = asRecord(entry) - if (change === null) return null - const path = change['path'] - if (typeof path !== 'string' || path === '') return null - const action = change['action'] - // The action decides which word the row shows, so an unrecognized one is - // not a readable change — it would be presented as loaded or updated. - if (action !== 'set' && action !== 'replace' && action !== 'remove') return null - const digest = change['digest'] - if (seen.has(path)) continue - seen.add(path) - changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} }) - } - return changes.length === 0 ? null : changes -} - -/** - * Locale key for one reconciled file. The baseline loads a file; a later delta - * distinguishes a newly reconciled path from a rewritten one, which `set` and - * `replace` already separate at the producer. - * @param action - the durable change action. - * @param baseline - whether this context is the startup/resume baseline. - * @returns the key naming what happened to that file. - */ -function instructionAction( - action: InstructionChange['action'], - baseline: boolean, -): 'message.context.instructions.removed' | 'message.context.instructions.loaded' - | 'message.context.instructions.added' | 'message.context.instructions.updated' { - if (action === 'remove') return 'message.context.instructions.removed' - if (baseline) return 'message.context.instructions.loaded' - return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated' -} - -/** - * `instructions` form: the files this context reconciled, then their text. - * - * The text keeps its `` framing verbatim — the framing is part - * of what the model read, so hiding it would misreport the request. - * @param props - Durable content, its source, and the locale seat. - * @returns The instructions context body, or the opaque body when the change - * list is unreadable. - */ -export function InstructionsBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - const changes = instructionChanges(source) - if (changes === null) return - const baseline = asRecord(source)?.['baseline'] === true - return ( - <> -
    - {changes.map(change => ( -
  • - {change.path} - - {t(instructionAction(change.action, baseline))} - -
  • - ))} -
- - - ) -} - -/** One catalog entry, as the durable source records it. */ -interface CatalogEntry { - name: string - description: string -} - -/** - * Catalog entries read off the source, or null when the record is not a usable - * catalog. All-or-nothing for the same reason as the instruction list: this body - * replaces the model-facing text, so a partial list would hide the only complete - * account of what the model read. - */ -function catalogEntries(source: unknown): CatalogEntry[] | null { - const record = asRecord(source) - const list = record === null ? undefined : record['entries'] - if (!Array.isArray(list)) return null - const entries: CatalogEntry[] = [] - for (const item of list as readonly unknown[]) { - const entry = asRecord(item) - if (entry === null) return null - const name = entry['name'] - const description = entry['description'] - if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null - entries.push({ name, description }) - } - // An empty list is a real catalog: a replacement with no entries retires - // every earlier name. Only an unreadable shape falls back. - return entries -} - -/** - * `catalog` form: the published entries as a list, read from the source rather - * than re-parsed out of the model-facing prose. - * - * A catalog whose source carries no usable entries falls through to the opaque - * body, so an older or hand-edited log still shows its text. - * @param props - Durable content, its source, and the locale seat. - * @returns The catalog context body, or the opaque body when the entry list is - * unreadable. - */ -export function CatalogBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - const entries = catalogEntries(source) - if (entries === null) return - const update = asRecord(source)?.['update'] === true - // Entry count is unbounded (a provider may publish any number of skills), and - // the scrollport bounds height, not node count — so the list bounds itself. - const shown = entries.slice(0, MAX_ENTRIES) - const rest = unknownBlocks(content) - return ( - <> - {update &&

{t('message.context.catalog.replaced')}

} -
    - {shown.map((entry, index) => ( - // Index key: a hand-edited or foreign log may repeat a name, and a - // duplicate React key would drop a row the model did see. -
  • - {entry.name} - {entry.description} -
  • - ))} -
- {shown.length < entries.length && ( -

- {t('message.context.catalog.more', { count: entries.length - shown.length })} -

- )} - {/* The block union is merge-extensible: a catalog message carrying an - unknown block still shows it rather than dropping model-visible content. */} - - - ) -} - -/** One named contribution to a runtime snapshot, as the durable source records it. */ -interface SnapshotSection { - name: string - text: string -} - -/** Snapshot sections read off the source, or null when the record is unusable. */ -function snapshotSections(source: unknown): SnapshotSection[] | null { - const record = asRecord(source) - const list = record === null ? undefined : record['sections'] - if (!Array.isArray(list)) return null - const sections: SnapshotSection[] = [] - for (const item of list as readonly unknown[]) { - const section = asRecord(item) - if (section === null) return null - const name = section['name'] - const text = section['text'] - if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null - sections.push({ name, text }) - } - return sections.length === 0 ? null : sections -} - -/** - * `snapshot` form: the named contributions this snapshot assembled, in order. - * - * The sections are the same bytes the model read, split at the boundaries the - * producer assembled them on, so a reader sees which subsystem contributed - * which state instead of one undifferentiated wall. - * - * One sentence of the model-facing text is NOT in any section: the producer's - * framing line declaring that this snapshot supersedes earlier ones. Unlike the - * `` wrapper an instruction context carries — which wraps - * content and cannot be separated from it — that line states the form's own - * semantics, so the body states them as a caption instead of reprinting the - * joined prose beside the sections it was split from. - * @param props - Durable content, its source, and the locale seat. - * @returns The snapshot context body, or the opaque body when unreadable. - */ -export function SnapshotBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - const sections = snapshotSections(source) - /* v8 ignore next -- contextBody reads the sections before choosing this body. */ - if (sections === null) return - return ( - <> -

- {t('message.context.snapshot.supersedes')} -

-
- {sections.map((section, index) => ( -
-
{section.name}
-
{boundedText(section.text, t)}
-
- ))} -
- - ) -} - -/** - * `notice` form: what just happened, with the model-facing text beneath it. - * - * The one-line account also rides the collapsed row ({@link contextBody}), so a - * notice is usually readable without expanding at all. - * @param props - Durable content, its source, and the locale seat. - * @returns The notice context body. - */ -export function NoticeBody({ content, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - return -} - -/** - * `relay` form: which agent sent this, then what it said. - * - * The sender is an opaque session id; it is shown as a field rather than a - * label, because this client cannot resolve it to a title. - * @param props - Durable content, its source, and the locale seat. - * @returns The relay context body. - */ -export function RelayBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - const sender = relaySender(source) - /* v8 ignore next -- contextBody resolves the sender before choosing this body. */ - if (sender === null) return - return ( - <> -

- {t('message.context.relay.from', { session: sender })} -

- - - ) -} - -/** The sending agent's session id, or null when the record does not name one. */ -function relaySender(source: unknown): string | null { - const sender = asRecord(source)?.['senderSessionId'] - return typeof sender === 'string' && sender !== '' ? sender : null -} - -/** One recalled session, as the durable source records it. */ -interface RecalledSession { - label: string - retained: number - omitted: number - truncated: boolean -} - -/** Recalled sessions read off the source, or null when the record is unusable. */ -function recalledSessions(source: unknown): RecalledSession[] | null { - const record = asRecord(source) - const list = record === null ? undefined : record['references'] - if (!Array.isArray(list)) return null - const sessions: RecalledSession[] = [] - for (const item of list as readonly unknown[]) { - const reference = asRecord(item) - if (reference === null) return null - const label = reference['label'] - const retained = reference['retainedMessages'] - const omitted = reference['omittedMessages'] - const truncated = reference['truncated'] - // Completeness is the fact this card exists to report, so a reference that - // cannot state it is not a readable recall — showing the label alone would - // present a confident card over unknown loss. - if (typeof label !== 'string' || label === '' - || typeof retained !== 'number' || typeof omitted !== 'number' - || typeof truncated !== 'boolean') return null - sessions.push({ label, retained, omitted, truncated }) - } - return sessions.length === 0 ? null : sessions -} - -/** - * `recall` form: which sessions this material came from and how much of each - * survived the read, then the material itself. - * - * Completeness is the fact a reader needs first: recalled context is bounded on - * the way in, so a card that hid the omitted count would overstate what the - * model received. - * @param props - Durable content, its source, and the locale seat. - * @returns The recall context body, or the opaque body when unreadable. - */ -export function RecallBody({ content, source, t }: { - content: ContextMessageNode['content'] - source: unknown - t: Translate -}): ReactNode { - const sessions = recalledSessions(source) - if (sessions === null) return - return ( - <> -
    - {sessions.map((session, index) => ( -
  • - {session.label} - - {t('message.context.recall.counts', { - retained: session.retained, - omitted: session.omitted, - })} - - {session.truncated && ( - {t('message.context.recall.truncated')} - )} -
  • - ))} -
- - - ) -} - -/** The one-line account a `notice` puts on its collapsed row, when it records one. */ -function noticeSummary(source: unknown): string | null { - const summary = asRecord(source)?.['summary'] - return typeof summary === 'string' && summary !== '' ? summary : null -} - -/** - * Choose the body for one context node. - * - * Returns the form the body actually rendered as, which is not always the - * declared one: a declared form whose fields are unreadable falls back to - * opaque, and the caller labels the row with what it really shows. - * `summary` is the collapsed row's one-line account, which only a `notice` - * records: its whole point is being readable without expanding. - * @param form - the producer-declared form projected onto the node. - * @param props - durable content, its source, and the locale seat. - * @returns the rendered form (null for opaque), its collapsed summary, and its body. - */ -export function contextBody( - form: ContextMessageNode['form'], - props: { content: ContextMessageNode['content']; source: unknown; t: Translate }, -): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } { - const opaque = { rendered: null, summary: null, body: } - switch (form) { - case 'instructions': - return instructionChanges(props.source) === null - ? opaque - : { rendered: 'instructions', summary: null, body: } - case 'catalog': - return catalogEntries(props.source) === null - ? opaque - : { rendered: 'catalog', summary: null, body: } - case 'snapshot': - return snapshotSections(props.source) === null - ? opaque - : { rendered: 'snapshot', summary: null, body: } - case 'notice': { - const summary = noticeSummary(props.source) - return summary === null - ? opaque - : { rendered: 'notice', summary, body: } - } - case 'relay': - return relaySender(props.source) === null - ? opaque - : { rendered: 'relay', summary: null, body: } - case 'recall': - return recalledSessions(props.source) === null - ? opaque - : { rendered: 'recall', summary: null, body: } - case null: - return opaque - /* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new - KnownContextForm here rather than letting it degrade to opaque silently. */ - default: { - const unreachable: never = form - throw new Error(`unreachable context form: ${String(unreachable)}`) - } - } -} diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css deleted file mode 100644 index 788322535c..0000000000 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. - SystemPromptRow reuses this sheet so both disclosures share one body. */ - -.root { - min-width: 0; -} - -.root[data-open] { - padding-bottom: 4px; -} - -.chevron { - color: var(--dsw-alias-label-secondary); -} - -/* Separator and producer name beside the role title: ToolRow's summary geometry, - so the two disclosure rows keep one 24px rhythm and one separator shape. */ -.sep { - flex: none; - width: 2px; - height: 2px; - margin: 0 8px; - border-radius: 1px; - background: var(--dsw-alias-label-caption); -} - -.source { - flex: none; - min-width: 0; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); - line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - text-overflow: ellipsis; - white-space: nowrap; -} - -/* A notice's one-line account: the reason it rarely needs expanding. */ -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); - line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - text-overflow: ellipsis; - white-space: nowrap; -} - -.body { - box-sizing: border-box; - /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ - width: calc(100% - 22px - var(--dsh-content-font-delta, 0px)); - max-height: 141px; - margin: 4px 0 0 calc(22px + var(--dsh-content-font-delta, 0px)); - overflow: auto; - padding: 10px 16px 12px 12px; - border: none; - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - color: var(--dsw-alias-label-tertiary); - /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */ - font: 400 11px/16px var(--ds-font-family-code); -} diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx deleted file mode 100644 index 49704cca03..0000000000 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from 'react' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ContextMessageNode } from '../contract/snapshot.ts' -import { contextBody } from './ContextBody.tsx' -import css from './ContextInjectionRow.module.css' - -/** Props for the logged non-user message presentation. */ -export interface ContextInjectionRowProps { - content: ContextMessageNode['content'] - source: ContextMessageNode['source'] - /** Role and producer name projected from the durable source. */ - producer: ContextMessageNode['producer'] - /** Producer-declared information form; null renders the opaque body. */ - form: ContextMessageNode['form'] - /** The owning view's locale seat, passed down as a plain prop. */ - t: ChatViewSlotProps['t'] -} - -/** - * Render logged context with the Tool calls disclosure chrome from Figma. - * - * The header names the role the context plays and, beside it, the producer the - * durable source identifies, so a reader can tell an injected skill catalog - * from a workspace instruction file or a recalled session without expanding. - * The expanded body follows the producer-declared form; an absent or unknown - * form renders the opaque body. - * @param props - Durable content, its projected producer role/name and form, and the locale seat. - * @returns A collapsed context row with a bounded, form-specific body. - */ -export function ContextInjectionRow({ content, source, producer, form, t }: ContextInjectionRowProps) { - const [open, setOpen] = useState(false) - // Resolved rather than declared: a form whose fields are unreadable renders - // the opaque body, and the marker must say what the row actually shows. - const { rendered, summary, body } = contextBody(form, { content, source, t }) - - return ( - - : } - chevronClassName={css.chevron} - title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} - collapsedContent={producer.label === null ? undefined : ( - /* ToolRow's separator shape: an aria-hidden dot, so the accessible name - stays the two readable parts and the two disclosure rows expose one - name shape. A source that names no producer drops the dot with it. */ - <> - - {producer.label} - {summary !== null && ( - <> - - {summary} - - )} - - )} - keepContentWhenOpen - open={open} - expandable - expandOnRowClick - onToggle={() => { setOpen(value => !value) }} - > -
- {body} -
-
- ) -} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index 513a826c46..a4bb6ef0cc 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -3,32 +3,6 @@ flex-direction: column; } -.row { - position: relative; - overflow: hidden; -} - -.root[data-state='running'] .row::after { - content: ''; - position: absolute; - inset-block: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-command-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-command-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { flex-shrink: 0; } @@ -39,6 +13,7 @@ .title { font-weight: 400; + transition: color 100ms ease; } .separator { @@ -59,6 +34,12 @@ line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not([data-error]) { + color: var(--dsw-alias-label-primary); } .summary[data-error], @@ -78,9 +59,3 @@ font: var(--dsw-font-markdown-code-block-small); white-space: pre-wrap; } - -@media (prefers-reduced-motion: reduce) { - .root[data-state='running'] .row::after { - animation: none; - } -} diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index 2864836301..f532f112ea 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { + DisclosureRow, IconApiOutlineRegular, TextShimmer, +} from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' import css from './GenericCommandCard.module.css' @@ -29,6 +31,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa // command name. const title = node.name ?? t('command.title') const state = stateOf(node.outcome) + const running = state === 'running' const body = text !== undefined && text.includes('\n') ? text : null const open = expanded && body !== null return ( @@ -41,7 +44,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa titleClassName={css.title} chevronClassName={css.chevron} icon={} - title={title} + title={running ? {title} : title} open={open} expandable={body !== null} expandOnRowClick @@ -50,7 +53,9 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa collapsedContent={( <> - {summary} + + {running ? {summary} : summary} + )} > diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index e1875f5ce2..6da1069508 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -6,7 +6,6 @@ import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' -import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -337,20 +336,6 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ ) }) -/** Injected-context keyed Chat renderer. */ -export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) { - const data = node.data - return ( - - ) -}) - /** Automatic compaction keyed Chat renderer. */ export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) { return diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 3ca51f8d85..30e01159c7 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -83,17 +83,12 @@ text-overflow: ellipsis; } -.summary[data-follow-end] { - display: flex; - justify-content: flex-end; +.summary[data-streaming] { + mask-image: linear-gradient(to right, black calc(100% - 48px), transparent); } -.summary[data-follow-end] .summaryText { - flex: 0 0 auto; - width: max-content; - min-width: 100%; +.summary[data-streaming] .summaryText { overflow: visible; - text-align: start; text-overflow: clip; } diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 4d484af95b..ef2c1729e1 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -11,25 +11,41 @@ function firstLine(text: string): string { return newline === -1 ? text : text.slice(0, newline) } -function latestLine(text: string): string { - const visible = text.trimEnd() - const newline = visible.lastIndexOf('\n') - return newline === -1 ? visible : visible.slice(newline + 1) +function latestCompletedParagraphFirstLine(text: string): string { + let summary = '' + let paragraphStart = 0 + const separator = /\r?\n[\t ]*\r?\n/g + while (true) { + const nextParagraph = separator.exec(text) + const paragraphEnd = nextParagraph === null ? text.length + : nextParagraph.index + nextParagraph[0].indexOf('\n') + const newline = text.indexOf('\n', paragraphStart) + if (newline !== -1 && newline <= paragraphEnd) { + const candidate = text.slice(paragraphStart, newline).trim() + if (candidate !== '') summary = candidate + } + if (nextParagraph === null) return summary + paragraphStart = nextParagraph.index + nextParagraph[0].length + } } /** - * Render one assistant reasoning block collapsed until the reader opens it. The - * collapsed summary omits double-asterisk markers; expanded content renders - * the complete Markdown with secondary typography. + * Render one assistant reasoning block as the Think disclosure row. The + * settled collapsed row shows only its title in Compact mode. Other modes + * preview the first line. A streaming summary advances only after the first + * line of a paragraph completes. Summaries omit double-asterisk markers; + * expanded content renders the complete Markdown with secondary typography. + * @param props.compact - whether work details use Compact mode. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. * @param props.t - conversation locale seat for status and Markdown actions. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { +export function ReasoningRow({ text, running, compact, t }: { compact: boolean; text: string; running: boolean; t: ChatViewSlotProps['t'] }) { const [expanded, setExpanded] = useState(false) const labels = useMemo(() => markdownLabels(t), [t]) - const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') + const summary = (running ? latestCompletedParagraphFirstLine(text) : firstLine(text)).replaceAll('**', '') + const showSummary = (running || !compact) && summary !== '' return (
{ setExpanded(value => !value) }} - collapsedContent={( + collapsedContent={showSummary ? ( <> - + {summary} - )} + ) : undefined} >
diff --git a/packages/client/ui-chat/src/client/chat/StepProcessList.module.css b/packages/client/ui-chat/src/client/chat/StepProcessList.module.css new file mode 100644 index 0000000000..ec23e9246e --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/StepProcessList.module.css @@ -0,0 +1,113 @@ +.root { + min-width: 0; +} + +.title { + display: flex; + max-width: 100%; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: none; + color: var(--dsw-alias-label-secondary); + font: inherit; + font-size: var(--dsh-content-font-size, 14px); + text-align: left; + cursor: pointer; + transition: color 100ms ease; +} + +.title:hover { + color: var(--dsw-alias-label-primary); +} + +.leading { + position: relative; + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--dsw-alias-label-tertiary); +} + +.activityIcon, +.chevron { + position: absolute; + inset: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 100ms ease; +} + +.activityIcon { + opacity: 1; +} + +.chevron { + opacity: 0; +} + +.title:is(:hover, :focus-visible) .activityIcon { + opacity: 0; +} + +.title:is(:hover, :focus-visible) .chevron { + opacity: 1; +} + +.title[aria-expanded="true"] .activityIcon { + opacity: 0; +} + +.title[aria-expanded="true"] .chevron { + opacity: 1; +} + +.title[aria-expanded="true"] { + padding-bottom: 16px; +} + +.body { + --dsh-chat-flow-gap: 8px; + max-height: min(400px, 50vh); + overflow-y: auto; + overscroll-behavior-y: auto; + scrollbar-gutter: stable; +} + +.label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fadeTop { + mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 100%); +} + +.fadeBottom { + mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%); +} + +.fadeTop.fadeBottom { + mask-image: linear-gradient( + to bottom, + transparent 0, + #000 24px, + #000 calc(100% - 24px), + transparent 100% + ); +} + +@media (prefers-reduced-motion: reduce) { + .title, + .activityIcon, + .chevron { + transition: none; + } +} diff --git a/packages/client/ui-chat/src/client/chat/StepProcessList.tsx b/packages/client/ui-chat/src/client/chat/StepProcessList.tsx new file mode 100644 index 0000000000..61891b400d --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/StepProcessList.tsx @@ -0,0 +1,199 @@ +/** Nested process disclosures retain the existing whole-turn visibility owner. */ +import { + memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode, +} from 'react' +import { + IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, + IconChevronUpOutlineRegular, + IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, IconPlanOutlineRegular, + IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, IconThinkOutlineRegular, TextShimmer, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ProcessActivity, ProcessActivitySummary, ProcessRange } from '../contract/step-process.ts' +import { turnProcessAlwaysOpen } from '../contract/turn-process.ts' +import { storedTurnProcessEntry } from '../stores.ts' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { processTitle } from './step-process.ts' +import { useSearchableHidden } from './searchable-hidden.ts' +import css from './StepProcessList.module.css' +import flowCss from './ChatView.module.css' + +type SeatProps = Omit, 'nodeKey'> +type ListProps = SeatProps & { readonly useChat: ChatViewSlotProps['useChat']; readonly expandedSteps: boolean } + +interface ProcessScrollEdges { + readonly canScrollUp: boolean + readonly canScrollDown: boolean +} + +const PROCESS_SCROLL_AT_REST: ProcessScrollEdges = { canScrollUp: false, canScrollDown: false } +const PROCESS_TITLE_MINIMUM_MS = 150 + +type ProcessTitleActivity = ProcessActivity | 'thinking' + +interface LiveProcessTitle { + readonly activity: ProcessTitleActivity + readonly detail: string +} + +const PROCESS_ICONS: Record = { + thinking: , + read: , + search: , + edit: , + commands: , + code: , + webSearch: , + webFetch: , + subagents: , + plan: , + questions: , + tools: , +} + +function processScrollEdges(element: HTMLElement): ProcessScrollEdges { + return { + canScrollUp: element.scrollTop > 1, + canScrollDown: element.scrollTop < element.scrollHeight - element.clientHeight - 1, + } +} + +function sameProcessScrollEdges(left: ProcessScrollEdges, right: ProcessScrollEdges): boolean { + return left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown +} + +function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean { + return left.activity === right.activity && left.detail === right.detail +} + +function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle { + const [displayed, setDisplayed] = useState(desired) + const displayedRef = useRef(displayed) + const desiredRef = useRef(desired) + const displayedAtRef = useRef(Date.now()) + useEffect(() => { + desiredRef.current = desired + if (!active || sameLiveProcessTitle(displayedRef.current, desired)) return + const remaining = PROCESS_TITLE_MINIMUM_MS - (Date.now() - displayedAtRef.current) + const commit = (): void => { + const next = desiredRef.current + displayedRef.current = next + displayedAtRef.current = Date.now() + setDisplayed(next) + } + if (remaining <= 0) { + commit() + return + } + const timer = setTimeout(commit, remaining) + return () => { clearTimeout(timer) } + }, [active, desired.activity, desired.detail]) + return active ? displayed : desired +} + +/** Ordered chat seats with configurable process defaults between assistant responses. */ +export const ChatNodeList = memo(function ChatNodeList({ useChat, expandedSteps, ...seatProps }: ListProps) { + const layout = useChat(snapshot => snapshot.stepProcesses.layout) + return layout.flatMap(range => expandedSteps && range.process + ? range.seats.map(seat => ) + : range.process + ? + : ) +}) + +const StepProcessSeat = memo(function StepProcessSeat({ groupKey, useChat, ...seatProps }: SeatProps & { + readonly groupKey: string + readonly useChat: ChatViewSlotProps['useChat'] +}) { + const group = useChat(snapshot => snapshot.stepProcesses.get(groupKey)) + return group === undefined ? null : +}) + +function StepProcess({ range, nodes, summary, ...seatProps }: SeatProps & { + readonly range: ProcessRange + readonly nodes: readonly ChatNode[] + readonly summary: ProcessActivitySummary +}) { + const { useChatNodeProcess, useStore, actions, t } = seatProps + const [open, setOpen] = useState(false) + const bodyId = useId() + const presentation = useChatNodeProcess(range.seats[0].nodeKey) + const spec = presentation?.spec + const stored = useStore(state => spec === undefined ? undefined : storedTurnProcessEntry(state, spec.turn)) + const alwaysOpen = (presentation !== undefined && !presentation.turnClosed) + || nodes.some(turnProcessAlwaysOpen) + const outerFoldable = (!seatProps.historyIncomplete || presentation?.turnStarted === true) + && spec !== undefined + const outerHidden = outerFoldable && !alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0) + const revealOuter = useCallback(() => { + if (spec !== undefined && !alwaysOpen) actions.setTurnProcessOpen(spec.turn, (spec.answerStep ?? 0), true) + }, [actions, spec, alwaysOpen]) + const rootRef = useSearchableHidden(outerHidden, revealOuter) + useEffect(() => { + if (outerHidden && rootRef.current?.hasAttribute('hidden')) setOpen(false) + }, [outerHidden, rootRef]) + const reveal = useCallback(() => { setOpen(true) }, []) + const bodyRef = useSearchableHidden(!open, reveal) + const [scrollEdges, setScrollEdges] = useState(PROCESS_SCROLL_AT_REST) + const syncScrollEdges = useCallback(() => { + const body = bodyRef.current + if (body === null || !open) { + setScrollEdges(current => sameProcessScrollEdges(current, PROCESS_SCROLL_AT_REST) + ? current + : PROCESS_SCROLL_AT_REST) + return + } + const next = processScrollEdges(body) + setScrollEdges(current => sameProcessScrollEdges(current, next) ? current : next) + }, [bodyRef, open]) + useLayoutEffect(() => { + syncScrollEdges() + const body = bodyRef.current + if (body === null || !open || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(syncScrollEdges) + observer.observe(body) + for (const child of body.children) observer.observe(child) + return () => { observer.disconnect() } + }, [bodyRef, nodes.length, open, syncScrollEdges]) + const desiredLiveTitle: LiveProcessTitle = { + activity: summary.running ?? 'thinking', + detail: summary.runningDetail, + } + const liveTitle = useStableLiveProcessTitle(desiredLiveTitle, !range.closed) + const label = range.closed + ? processTitle(summary, true, t) + : t(`message.stepProcess.${liveTitle.activity}`) + const detail = !seatProps.compactTranscript && !range.closed + ? liveTitle.detail + : '' + const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}` + const activity = range.closed ? summary.counts[0]?.kind ?? 'thinking' : liveTitle.activity + const bodyClasses = [css.body, flowCss.processBody] + if (scrollEdges.canScrollUp) bodyClasses.push(css.fadeTop) + if (scrollEdges.canScrollDown) bodyClasses.push(css.fadeBottom) + return ( +
+ +
+ {range.seats.map(seat => )} +
+
+ ) +} diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx deleted file mode 100644 index 0136acbfd9..0000000000 --- a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { memo, useState } from 'react' -import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import { OpaqueBody } from './ContextBody.tsx' -import css from './ContextInjectionRow.module.css' - -/** Props for one complete system prompt disclosure. */ -export interface SystemPromptRowProps { - /** Complete model-visible prompt text. */ - text: string - /** True when the prompt replaced an earlier one from this position in the history. */ - update?: boolean - /** The owning view's locale seat. */ - t: ChatViewSlotProps['t'] -} - -/** - * Render one complete system prompt as a collapsed disclosure whose expanded - * body is the same opaque context chrome: 141px code-block scrollport and - * model-facing text with its real line breaks. An in-history update uses the - * same row under its own title. - * @param props - Complete prompt text, whether it is an update, and the locale seat. - * @returns The system-prompt disclosure row. - */ -export function SystemPromptRow({ text, update = false, t }: SystemPromptRowProps) { - const [open, setOpen] = useState(false) - return ( - } - chevronClassName={css.chevron} - title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')} - open={open} - expandable - expandOnRowClick - onToggle={() => { setOpen(value => !value) }} - > -
- -
-
- ) -} - -/** System-prompt keyed Chat renderer. */ -export const SystemPromptNodeView = memo(function SystemPromptNodeView({ - node, t, -}: Pick, 'node' | 't'>) { - return -}) diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css index 7c6025ca87..dbc29ec5c1 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css @@ -4,14 +4,23 @@ align-items: center; width: 100%; min-width: 0; - height: 33px; + height: calc(33px + var(--dsh-content-font-delta, 0px)); padding: 0 0 8px; border: none; border-bottom: 0.5px solid var(--dsw-alias-border-l2); background: none; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); cursor: pointer; text-align: left; + transition: color 100ms ease; +} + +.root:disabled { + cursor: default; +} + +.root:not(:disabled):hover { + color: var(--dsw-alias-label-primary); } .root:not([data-open]) { @@ -20,28 +29,28 @@ .chevron { flex: none; - width: 16px; - height: 16px; - margin-left: 6px; - color: var(--dsw-alias-label-tertiary); - transform: rotate(-90deg); + width: 14px; + height: 14px; + margin-left: 4px; + color: var(--dsw-alias-label-caption); transition: transform 100ms ease; } .root[data-open] .chevron { - transform: rotate(0deg); + transform: rotate(180deg); } .label { min-width: 0; overflow: hidden; - font-size: 14px; - line-height: 24px; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; } @media (prefers-reduced-motion: reduce) { + .root, .chevron { transition: none; } diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index 4969b3f383..2224cd2873 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -1,6 +1,9 @@ -import { memo } from 'react' +import { memo, useEffect, useState } from 'react' import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' +import { turnProcessAlwaysOpen } from '../contract/turn-process.ts' +import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts' +import a11yCss from './accessibility.module.css' import css from './TurnProcessNodeView.module.css' /** Turn-level process disclosure controller. */ @@ -8,53 +11,58 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ node, turnProcess, t, }: ChatNodeViewProps<'turn-process'>) { if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state') - if (!turnProcess.foldable) return null const open = turnProcess.open - const labels: string[] = [] - if (node.data.toolCallCount > 0) { - labels.push(t( - node.data.toolCallCount === 1 - ? 'message.turnProcess.toolCalls.one' - : 'message.turnProcess.toolCalls.other', - { count: node.data.toolCallCount }, - )) - } - if (node.data.messageCount > 0) { - labels.push(t( - node.data.messageCount === 1 - ? 'message.turnProcess.messages.one' - : 'message.turnProcess.messages.other', - { count: node.data.messageCount }, - )) - } - if (node.data.subagentCount > 0) { - labels.push(t( - node.data.subagentCount === 1 - ? 'message.turnProcess.subagents.one' - : 'message.turnProcess.subagents.other', - { count: node.data.subagentCount }, - )) - } - const label = labels.length === 0 - ? t('message.turnProcess.thoughtForAWhile') - : labels.join(t('message.turnProcess.separator')) + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + const [now, setNow] = useState(Date.now) + const ticking = turnProcess.foldable && turn?.status === 'open' + useEffect(() => { + if (!ticking) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [ticking]) + if (!turnProcess.foldable) return null + const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node) + const running = turn?.status === 'open' + const reason = turn?.end?.data.reason.kind + const elapsedMs = turn?.start === undefined ? undefined + : Math.max(1000, (turn.end?.time ?? now) - turn.start.time) + const duration = elapsedMs === undefined ? undefined + : running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t) + // Other end reasons retain elapsed time; only cancellation and failure replace it. + const label = running + ? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration }) + : reason === 'aborted' ? t('message.stopped') + : reason === 'error' ? t('message.turnProcess.failed') + : duration === undefined ? t('message.turnProcess.worked') + : t('message.turnProcess.took', { duration }) + const announcement = running ? t('chat.deepDiving') + : reason === 'aborted' ? t('message.stopped') + : reason === 'error' ? t('message.turnProcess.failed') + : t('message.turnProcess.worked') return ( - + <> + {announcement} + + ) }) diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index a03c46b2ed..ce1e4dcda5 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -16,9 +16,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ }: TurnTailNodeViewProps) { const detailed = usePerformanceUsage(mode => mode) === 'detailed' const data = node.data - const hasLaterChatNode = useChat(snapshot => - snapshot.locations.getTurn(data.turn).at(-1) !== node.key) - const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn) + const hasLaterChatNode = useChat(snapshot => snapshot.stepProcesses.footer(data.turn)?.hasLaterChatNode ?? false) + const endsWithResponse = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn + && (snapshot.stepProcesses.footer(data.turn)?.endsWithResponse ?? false)) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined @@ -37,7 +37,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
{tail} > = { + request: IconContextInjectionOutlineRegular, + goal: IconGoalOutlineRegular, + agent: IconPaperPlaneOutlineRegular, + team: IconAgentPresetOutlineRegular, + subagent: IconAgentPresetOutlineRegular, + github: IconBranchOutlineRegular, + webhook: IconGlobeOutlineRegular, + schedule: IconAlarmClockOutlineRegular, + job: IconQueueOutlineRegular, + plugin: IconCordisPluginOutlineRegular, +} + +/** Render recorded trigger attribution above the whole-Turn disclosure. */ +export function TurnTriggerNodeView({ node, t }: ChatNodeViewProps<'turn-trigger'>) { + const [open, setOpen] = useState(false) + const bodyId = useId() + const details = turnTriggerDetails(node.data) + const TriggerIcon = TRIGGER_ICONS[details.icon] + const date = new Date(node.data.time) + const time = formatMessageClock(node.data.time, t) + return ( +
+ + {open &&
+

{t('message.trigger.explanation')}

+
+
} +
+ ) +} diff --git a/packages/client/ui-chat/src/client/chat/message-chrome.ts b/packages/client/ui-chat/src/client/chat/message-chrome.ts index 36c4a3f0bd..7f6826fc96 100644 --- a/packages/client/ui-chat/src/client/chat/message-chrome.ts +++ b/packages/client/ui-chat/src/client/chat/message-chrome.ts @@ -8,6 +8,10 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> /** The elapsed-duration share of the conversation dictionary. */ export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes' | 'duration.hours'> + +/** Refresh interval for whole-second live run clocks. */ +export const LIVE_RUN_CLOCK_INTERVAL_MS = 1000 + function pad2(n: number): string { return String(n).padStart(2, '0') } @@ -55,6 +59,24 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string { : t('duration.seconds', { seconds }) } +/** + * Localized live elapsed time without padded seconds or early rollover. + * @param ms - Elapsed duration in milliseconds (negatives clamp to zero). + * @param t - Translate seat supplying the duration templates. + * @returns Whole seconds without a leading zero; minutes start at 60 seconds + * and hours start at exactly 60 minutes. + */ +export function formatLiveRunDuration(ms: number, t: RunDurationTranslate): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor(totalSeconds / 60) % 60 + const seconds = String(totalSeconds % 60) + if (hours > 0) return t('duration.hours', { hours, minutes: pad2(minutes), seconds }) + return minutes > 0 + ? t('duration.minutes', { minutes, seconds }) + : t('duration.seconds', { seconds }) +} + /** * Decode-throughput figure: whole tokens from ten up, one decimal below. * @param tps - Tokens per second. diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index f8f78000f1..e7d932d701 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -5,11 +5,11 @@ import { NS } from '../locale.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' import { - CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + CompactionNodeView, RetryNodeView, TurnErrorNodeView, TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' -import { SystemPromptNodeView } from './SystemPromptRow.tsx' import { TurnProcessNodeView } from './TurnProcessNodeView.tsx' +import { TurnTriggerNodeView } from './TurnTriggerNodeView.tsx' import { TurnTailNodeView } from './TurnTailNodeView.tsx' /** @@ -23,9 +23,7 @@ export function registerChatNodeRenderers(ctx: Context, performanceUsage: Observ ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( - { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) - ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( - { name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView)) + { name: 'conversation.chat.node', key: 'turn-trigger', locale: NS }, TurnTriggerNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/chat/step-process.ts b/packages/client/ui-chat/src/client/chat/step-process.ts new file mode 100644 index 0000000000..b2f6f9f4e8 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/step-process.ts @@ -0,0 +1,31 @@ +/** Localized titles for projected step-process activity. */ +import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ProcessActivitySummary } from '../contract/step-process.ts' + +/** + * Compose localized abstract status or the top three completed categories without counts. + * @param summary - ranked work and phase evidence for this range. + * @param closed - whether a following reply or turn boundary closed the range. + * @param t - Chat namespace translator. + * @returns the secondary disclosure title. + */ +export function processTitle( + summary: ProcessActivitySummary, + closed: boolean, + t: ChatViewSlotProps['t'], +): string { + if (!closed) return t(`message.stepProcess.${summary.running ?? 'thinking'}`) + const labels = summary.counts.slice(0, 3).map(({ kind }) => t(`message.stepProcess.done.${kind}`)) + const first = labels[0] + if (first === undefined) return t('message.stepProcess.done.thinking') + const continuation = (label: string): string => label.charAt(0).toLowerCase() + label.slice(1) + const second = labels[1] + if (second === undefined) return first + if (labels.length === 2) { + const prefix = t('message.stepProcess.sharedPrefix') + const shared = prefix !== '' && first.startsWith(prefix) && second.startsWith(prefix) + return t('message.stepProcess.joinTwo', { first, second: continuation(shared ? second.slice(prefix.length) : second) }) + } + const title = [first, ...labels.slice(1).map(continuation)].join(t('message.stepProcess.comma')) + return summary.counts.length > 3 ? t('message.stepProcess.more', { title }) : title +} diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts new file mode 100644 index 0000000000..6555796e98 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts @@ -0,0 +1,72 @@ +/** Presentation of durable non-human messages claimed to begin a Turn. */ +import type { ContextMessageNode } from '../contract/snapshot.ts' +import type { ChatKey } from '../locale.ts' + +/** Existing primitive glyph selected for a Turn trigger's source family. */ +export type TurnTriggerIcon = 'agent' | 'github' | 'goal' | 'job' | 'plugin' | 'request' | 'schedule' | 'subagent' | 'team' | 'webhook' + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {} +} + +function field(source: Record, key: string): string { + return typeof source[key] === 'string' ? source[key] : '' +} + +/** + * Describe a waking message using its source and recognized producer framing. + * @param node - durable context, including the original notification body. + * @returns localized title key and source-family icon. + */ +export function turnTriggerDetails(node: ContextMessageNode): { + title: ChatKey + icon: TurnTriggerIcon +} { + const source = record(node.source) + const kind = field(source, 'kind') + const plugin = field(source, 'plugin') + let title: ChatKey = 'message.trigger.request' + let icon: TurnTriggerIcon = 'request' + switch (kind) { + case 'goal': { + title = 'message.trigger.goal' + icon = 'goal' + break + } + case 'agent-message': + title = 'message.trigger.agent' + icon = 'agent' + break + case 'team-message': + title = 'message.trigger.team' + icon = 'team' + break + case 'subagent-settled': { + title = 'message.trigger.subagent' + icon = 'subagent' + break + } + case 'webhook': { + const github = field(source, 'provider') === 'github' + title = github ? 'message.trigger.github' : 'message.trigger.webhook' + icon = github ? 'github' : 'webhook' + break + } + case 'plugin': + if (plugin === 'schedule') { + title = 'message.trigger.schedule' + icon = 'schedule' + } else if (plugin === 'tool-jobs') { + title = 'message.trigger.job' + icon = 'job' + } else if (plugin === 'cordis-host-runner') { + title = 'message.trigger.plugin' + icon = 'plugin' + } + break + default: + // Custom sources remain visible without attributing unrecorded identity or success. + break + } + return { title, icon } +} diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts new file mode 100644 index 0000000000..cc7f8f4da7 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -0,0 +1,14 @@ +/** Chat-only row visibility; durable events and trajectory inspection remain intact. */ +import type { ChatNode } from './chat-nodes.ts' + +/** + * Exclude infrastructure rows while retaining independent turn-trigger notices. + * @param node - projected Chat node. + * @returns whether the node contributes a visible Chat row. + */ +export function isVisibleChatNode(node: ChatNode): boolean { + return node.visibility === 'visible' + && node.kind !== 'system-prompt' + && node.kind !== 'context' + && !(node.kind === 'command' && node.data.name === 'permission') +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index d5b007b7cf..de1250d3e1 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -78,6 +78,9 @@ export interface ChatNodeTurnDataInjected { /** Stable owner currency delivered to a keyed Chat renderer. */ export interface ChatNodeOwnerProps { + /** Hide settled reasoning previews in Compact work details. */ + readonly compactTranscript?: boolean + cwd?: string | undefined /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void @@ -101,6 +104,8 @@ export interface ChatNodeOwnerProps { export interface TurnProcessOwnerProps { readonly spec: TurnProcessSpec readonly foldable: boolean + /** Whether the Turn contains process rows or inline reasoning to disclose. */ + readonly hasContent: boolean readonly open: boolean setOpen(open: boolean): void } diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 80f8654e7c..5b497bf078 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -3,6 +3,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode } from './chat-nodes.ts' import type { TurnProcessSpec } from './turn-process.ts' +import type { ChatStepProcessIndex } from './step-process.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig, @@ -74,6 +75,8 @@ export interface ChatLocationNodeIndex { export interface ChatTurnProcessPresentation { readonly turn: number readonly spec: TurnProcessSpec + /** Whether the loaded window includes this Turn's start event. */ + readonly turnStarted: boolean readonly turnClosed: boolean readonly hasExternalProcess: boolean readonly compactAnswer: boolean @@ -90,6 +93,7 @@ export interface LegacyConversationSlice { /** Incremental Chat publication with immutable order and stable live keyed readers. */ export interface ChatSnapshot { + readonly stepProcesses: ChatStepProcessIndex readonly order: readonly string[] readonly nodes: ChatNodeStore readonly locations: ChatLocationNodeIndex @@ -117,6 +121,7 @@ const EMPTY_NODE_PROCESS_SOURCE: ChatNodeProcessSource = { /** Empty Chat target used before a view builder is registered. */ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + stepProcesses: { layout: EMPTY_LIST, get: () => undefined, footer: () => undefined }, order: EMPTY_LIST, nodes: { get: () => undefined, diff --git a/packages/client/ui-chat/src/client/contract/step-process.ts b/packages/client/ui-chat/src/client/contract/step-process.ts new file mode 100644 index 0000000000..dd5ad70796 --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/step-process.ts @@ -0,0 +1,60 @@ +/** Stable step-group layout and per-group presentation published by Chat. */ +import type { ChatNode } from './chat-nodes.ts' + +/** One stable renderer seat, optionally restricted to part of an assistant node. */ +export interface ProcessSeat { + readonly nodeKey: string + readonly assistantPart?: 'reasoning' | 'response' +} + +/** A consecutive process range or a standalone transcript row. */ +export interface ProcessRange { + readonly key: string + readonly seats: readonly [ProcessSeat, ...ProcessSeat[]] + readonly process: boolean + readonly closed: boolean + readonly turn: number | undefined +} + +/** Tool categories used by abstract process titles. */ +export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code' + | 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools' + +/** Ranked tool categories and live detail for one consecutive process range. */ +export interface ProcessActivitySummary { + readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[] + readonly running: ProcessActivity | undefined + readonly runningDetail: string +} + +/** Layout changes only when renderer seats or their grouping change. */ +export type StepProcessLayout = Omit + +/** Current content and status for one stable group key. */ +export interface StepProcessGroup { + readonly range: ProcessRange + readonly nodes: readonly ChatNode[] + readonly summary: ProcessActivitySummary +} + +/** Footer facts computed from the visible Turn contents. */ +export interface TurnFooterPresentation { + readonly hasLaterChatNode: boolean + readonly endsWithResponse: boolean +} + +/** Data-layer process projection; content updates preserve unrelated groups. */ +export interface ChatStepProcessIndex { + /** Ordered renderer seats; unchanged during content-only streaming. */ + readonly layout: readonly StepProcessLayout[] + /** + * @param key - stable group key. + * @returns current group content, or absence after regrouping. + */ + get(key: string): StepProcessGroup | undefined + /** + * @param turn - owning Turn. + * @returns footer facts, or absence without a footer. + */ + footer(turn: number): TurnFooterPresentation | undefined +} diff --git a/packages/client/ui-chat/src/client/contract/store.ts b/packages/client/ui-chat/src/client/contract/store.ts index 78a248dd54..0153470214 100644 --- a/packages/client/ui-chat/src/client/contract/store.ts +++ b/packages/client/ui-chat/src/client/contract/store.ts @@ -3,7 +3,10 @@ /** Tool call identity as carried by Chat nodes. */ export type ToolCallId = string -/** One manually expanded Turn answer generation. */ +/** + * Manual disclosure override for an expanded completed answer. + * Zero represents completion without an answer. + */ export interface TurnProcessViewEntry { readonly turn: number readonly answerStep: number diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 15c90523bb..c69361bbe8 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -20,7 +20,7 @@ export interface TurnProcessSpec { const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ 'system-prompt', 'user', - 'steering', + 'steering', 'turn-trigger', 'turn-process', 'turn-error', 'turn-max-tokens', @@ -59,3 +59,15 @@ export function sameTurnProcessSpec(left: TurnProcessSpec, right: TurnProcessSpe export function isSubagentDelegationTool(name: string): boolean { return name === 'subagent' || name.startsWith('subagent_') } + +/** + * Whether a Turn must keep its process visible. + * @param node - node carrying the owning Turn location. + * @returns true during execution and after cancellation or failure. + */ +export function turnProcessAlwaysOpen(node: ChatNode | undefined): boolean { + const location = node?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return false + const reason = location.turn.end?.data.reason.kind + return location.turn.status === 'open' || reason === 'aborted' || reason === 'error' +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index fb91233786..9833db7f80 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -14,6 +14,7 @@ import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' import { ChatTurnProcessProjector } from './turn-process-presentation.ts' +import { ChatStepProcessProjector, processLayoutChanged } from './step-process-index.ts' const EMPTY_KEYS: readonly string[] = [] const EMPTY_TURNS: readonly number[] = [] @@ -336,8 +337,9 @@ function turnProcessPresentations( const location = node.location if (location.kind !== 'turn' && location.kind !== 'step') continue const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} - if ((node.kind === 'user' || node.kind === 'steering') - && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) { + if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger') + && (current.control?.data.controlAnchorSeq === location.turn.start?.seq + || node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY))) { presentations.set(location.turn.turn, { ...current, openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq), @@ -737,6 +739,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { case 'user': case 'steering': case 'context': + case 'turn-trigger': case 'command': case 'compaction': case 'turn-error': @@ -958,6 +961,7 @@ function partialContributionChanged( /** Incremental keyed Chat builder registered under the `chat` target. */ export class ChatSnapshotBuilder implements ConversationViewBuilder { + private readonly stepProcesses = new ChatStepProcessProjector() private readonly store = new MutableChatNodeStore() private readonly locations = new MutableChatLocationIndex() private readonly navigation = new MutableTurnNavigationIndex() @@ -984,6 +988,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false + let regroup = false const contentOnly: ChatConversationViewNode[] = [] for (const node of upserts) { const previous = this.store.get(node.key) + regroup ||= processLayoutChanged(previous as ChatNode | undefined, node as ChatNode) const nodeStructural = previous === undefined || previous.kind !== node.kind || previous.anchorSeq !== node.anchorSeq || previous.visibility !== node.visibility || locationIdentity(previous.location) !== locationIdentity(node.location) + regroup ||= nodeStructural structural ||= nodeStructural if (!nodeStructural) contentOnly.push(node) if (processPresentationInputChanged(previous as ChatNode | undefined, node as ChatNode, nodeStructural)) { @@ -1026,6 +1034,12 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key)) this.timeline = input.timeline const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline)) this.store.publish() @@ -1038,6 +1052,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder + readonly claimSeq: number + readonly claimedHuman: boolean } const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } const EMPTY_CURRENT_CLAIMED: ReadonlySet = new Set() -function materializePending(state: PendingState): string[] { +function materializePending(state: PendingState): InboxIdentity[] { const splices: PendingSplice[] = [] let current = state while (current.kind === 'splice') { @@ -56,10 +59,10 @@ function materializePending(state: PendingState): string[] { function withoutInserted( claimed: ReadonlySet, - inserted: readonly string[], + inserted: readonly InboxIdentity[], ): ReadonlySet { let next: Set | undefined - for (const id of inserted) { + for (const { id } of inserted) { if (!claimed.has(id)) continue next ??= new Set(claimed) next.delete(id) @@ -68,7 +71,7 @@ function withoutInserted( } /** - * Apply one next-step splice under the AgentLoop's durable event ordering. + * Apply one Inbox splice under the AgentLoop's durable event ordering. * An entered claim logs its complete message batch before another claim; a * rejected claim logs no messages, so only the current claim can classify a * later `user/message`. @@ -76,16 +79,19 @@ function withoutInserted( function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, + seq: number, ): InboxState { const priorPending = previous?.state.pending ?? EMPTY_PENDING - const inserted = splice.inserted.map(identity => identity.id) + const inserted = splice.inserted const removedCount = splice.removedCount ?? 0 if (removedCount > 0 && splice.outcome !== 'canceled') { const pending = materializePending(priorPending) const removed = pending.splice(splice.start, removedCount, ...inserted) return { pending: { kind: 'snapshot', ids: pending }, - currentClaimed: new Set(removed), + currentClaimed: new Set(removed.map(message => message.id)), + claimSeq: seq, + claimedHuman: removed.some(message => message.source.kind === 'user'), } } const currentClaimed = withoutInserted( @@ -101,34 +107,37 @@ function applySplice( inserted, }, currentClaimed, + claimSeq: previous?.state.claimSeq ?? -1, + claimedHuman: previous?.state.claimedHuman ?? false, } } -const NEXT_STEP_INBOX_KIND = 'inbox-next-step' - -/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */ -export const nextStepInboxDefinition: ConversationNodeDefinition = { - kind: NEXT_STEP_INBOX_KIND, - match: (event) => { - if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') { - return { id: String(event.seq), role: 'start' } - } - return null - }, - start: (_context, match, reader) => { - if (match.event.type !== 'agent/inbox/spliced') { - throw new Error('inbox-next-step start requires agent/inbox/spliced') - } - return applySplice(reader.previous(NEXT_STEP_INBOX_KIND), match.event.data) - }, - update: context => context.state, - publication: () => 'none', +function inboxDefinition(target: 'next-step' | 'next-turn'): ConversationNodeDefinition { + const kind = `inbox-${target}` + return { + kind, + match: event => event.type === 'agent/inbox/spliced' && event.data.target === target + ? { id: String(event.seq), role: 'start' } : null, + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') throw new Error('inbox start requires agent/inbox/spliced') + return applySplice(reader.previous(kind), match.event.data, match.event.seq) + }, + update: context => context.state, + publication: () => 'none', + } } +/** Persistent next-step claims identify messages admitted into a running Turn. */ +export const nextStepInboxDefinition = inboxDefinition('next-step') + +/** Persistent next-turn claims identify messages that wake a new Turn. */ +export const nextTurnInboxDefinition = inboxDefinition('next-turn') + /** - * Register the next-step Inbox state used by Chat message classification. + * Register the Inbox state used by Chat message classification. * @param ctx - owning UI Conversation context. */ export function registerInboxConversationNodes(ctx: Context): void { ctx.uiConversation.events.register(nextStepInboxDefinition) + ctx.uiConversation.events.register(nextTurnInboxDefinition) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 823bbe3c93..f02b9c89bb 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -21,7 +21,7 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode { readonly skillNames?: readonly string[] } -type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | (ContextMessageNode & { readonly waking?: boolean }) declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -31,6 +31,8 @@ declare module '../contract/chat-nodes.ts' { steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode + /** Non-human input that starts a Turn. */ + 'turn-trigger': ContextMessageNode } } @@ -53,8 +55,18 @@ export const messageDefinition: ConversationNodeDefinition = { if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') const event = match.event if (event.data.source.kind !== 'user') { + const nextTurn = reader.previous('inbox-next-turn')?.state + const nextStep = reader.previous('inbox-next-step')?.state + const location = match.location + const turnStart = location.kind === 'step' ? location.turn.start?.seq : undefined + const idleSteer = location.kind === 'step' && location.step.step === 1 + && turnStart !== undefined && (nextStep?.claimSeq ?? -1) > turnStart + && (nextTurn?.claimSeq ?? -1) < turnStart && nextStep?.claimedHuman === false + && nextStep.currentClaimed.has(String(event.data.id)) + return { kind: 'context', + waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer, seq: event.seq, time: event.time, content: event.data.content, @@ -85,7 +97,10 @@ export const messageDefinition: ConversationNodeDefinition = { update: context => context.state, buildViewNode: (context) => { if (context.state === undefined) return null - return chatNode(context, context.state.kind, context.state.seq, context.state) + const waking = context.state.kind === 'context' + && context.start?.event.type === 'user/message' + && context.state.waking === true + return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/step-process-index.ts b/packages/client/ui-chat/src/client/conversation-nodes/step-process-index.ts new file mode 100644 index 0000000000..1a3854a95d --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/step-process-index.ts @@ -0,0 +1,135 @@ +/** Incremental process content over the ordered Chat renderer seats. */ +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' +import type { + ChatStepProcessIndex, ProcessRange, StepProcessGroup, StepProcessLayout, TurnFooterPresentation, +} from '../contract/step-process.ts' +import { processActivity, processRanges } from './step-process.ts' + +type ProcessInput = Pick + +function sameSeats(left: StepProcessLayout, right: StepProcessLayout | undefined): boolean { + return right !== undefined && left.key === right.key && left.process === right.process && left.turn === right.turn + && left.seats.length === right.seats.length && left.seats.every((seat, index) => + seat.nodeKey === right.seats[index]?.nodeKey && seat.assistantPart === right.seats[index].assistantPart) +} + +function lastContentKind(node: ChatNode | undefined): string | undefined { + if (node?.kind !== 'assistant-step') return node?.kind + return node.data.blocks.findLast(block => + (block.kind !== 'text' && block.kind !== 'reasoning') || block.text.trim() !== '')?.kind +} + +/** + * Recognize changes to grouping or footer eligibility, excluding body-only chunks. + * @param previous - previously published Node. + * @param next - current Node. + * @returns whether ordered ranges and footer facts need rebuilding. + */ +export function processLayoutChanged(previous: ChatNode | undefined, next: ChatNode): boolean { + if (previous === undefined || previous.kind !== next.kind || previous.visibility !== next.visibility + || previous.anchorSeq !== next.anchorSeq || previous.location.kind !== next.location.kind) return true + const left = previous.location + const right = next.location + if ((left.kind === 'turn' || left.kind === 'step') && (right.kind === 'turn' || right.kind === 'step') + && (left.turn.turn !== right.turn.turn || left.turn.status !== right.turn.status)) return true + if (previous.kind === 'turn-tail' && next.kind === 'turn-tail') return previous.data.closing !== next.data.closing + if (previous.kind !== 'assistant-step' || next.kind !== 'assistant-step') return false + return hasAssistantReplyContent(previous.data.blocks) !== hasAssistantReplyContent(next.data.blocks) + || previous.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') + !== next.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') + || lastContentKind(previous) !== lastContentKind(next) +} + +/** Owns stable layout, cached activity, and footer facts for one Chat snapshot builder. */ +export class ChatStepProcessProjector implements ChatStepProcessIndex { + layout: readonly StepProcessLayout[] = [] + private groups = new Map() + private readonly membership = new Map() + private footers = new Map() + + get(key: string): StepProcessGroup | undefined { return this.groups.get(key) } + footer(turn: number): TurnFooterPresentation | undefined { return this.footers.get(turn) } + + /** + * Rebuild after pagination or a renderer-seat boundary change, preserving unchanged groups. + * @param input - ordered Nodes and their current Turn lifecycle. + */ + replace(input: ProcessInput): void { + const ranges = processRanges(input) + const groups = new Map() + this.membership.clear() + for (const range of ranges) { + if (!range.process) continue + groups.set(range.key, this.project(range, input)) + for (const seat of range.seats) { + const keys = this.membership.get(seat.nodeKey) ?? [] + keys.push(range.key) + this.membership.set(seat.nodeKey, keys) + } + } + this.groups = groups + if (ranges.length !== this.layout.length || ranges.some((range, index) => !sameSeats(range, this.layout[index]))) { + this.layout = ranges + } + this.replaceFooters(input) + } + + /** + * Refresh only groups containing changed content; historical groups keep their identity. + * @param input - current keyed Nodes. + * @param keys - keys changed by this publication. + */ + update(input: ProcessInput, keys: readonly string[]): void { + const dirty = new Set() + for (const key of keys) for (const group of this.membership.get(key) ?? []) dirty.add(group) + for (const key of dirty) { + const previous = this.groups.get(key) + if (previous === undefined) throw new Error(`process membership references missing group ${key}`) + this.groups.set(key, this.project(previous.range, input)) + } + } + + private project(range: ProcessRange, input: ProcessInput): StepProcessGroup { + const nodes = range.seats.map(seat => input.nodes.get(seat.nodeKey) as ChatNode) + const previous = this.groups.get(range.key) + const sameNodes = previous !== undefined && previous.nodes.length === nodes.length + && previous.nodes.every((node, index) => node === nodes[index]) + const sameRange = previous !== undefined && sameSeats(previous.range, range) && previous.range.closed === range.closed + if (sameNodes && sameRange) return previous + return { + range: sameRange ? previous.range : range, + nodes: sameNodes ? previous.nodes : nodes, + summary: sameNodes ? previous.summary : processActivity(nodes), + } + } + + private replaceFooters(input: ProcessInput): void { + const turns = new Map() + for (const key of input.order) { + const node = input.nodes.get(key) as ChatNode + const location = node.location + if (location.kind !== 'turn' && location.kind !== 'step') continue + const nodes = turns.get(location.turn.turn) ?? [] + nodes.push(node) + turns.set(location.turn.turn, nodes) + } + const footers = new Map() + for (const [turn, nodes] of turns) { + const tail = nodes.find(node => node.kind === 'turn-tail') + if (tail?.kind !== 'turn-tail') continue + const hasLaterChatNode = nodes.some(node => node.kind !== 'turn-tail' && node.kind !== 'turn-max-tokens' + && node.anchorSeq > (tail.data.closing?.finalNode.seq ?? tail.data.seq)) + const last = nodes.findLast(node => isVisibleChatNode(node) && node.kind !== 'turn-tail' && node.kind !== 'turn-process') + const block = last?.kind === 'assistant-step' ? last.data.blocks.findLast(candidate => + (candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '') : undefined + const endsWithResponse = block !== undefined && hasAssistantReplyContent([block]) + const previous = this.footers.get(turn) + footers.set(turn, previous?.hasLaterChatNode === hasLaterChatNode && previous.endsWithResponse === endsWithResponse + ? previous : { hasLaterChatNode, endsWithResponse }) + } + this.footers = footers + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/step-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/step-process.ts new file mode 100644 index 0000000000..18a4f1a7e9 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/step-process.ts @@ -0,0 +1,175 @@ +/** Consecutive process material, separated by visible assistant responses and turn boundaries. */ +import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import type { ProcessSeat, ProcessRange, ProcessActivity, ProcessActivitySummary } from '../contract/step-process.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isRunningTool } from '../contract/chat-nodes.ts' +import type { ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' + +const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail', 'turn-process']) + +/** + * Divide loaded rows without altering durable nodes or response order. + * @param snapshot - current chat publication, including live keyed nodes. + * @returns ranges with stable first-seat keys and response-closed status. + */ +export function processRanges(snapshot: Pick): ProcessRange[] { + const ranges: ProcessRange[] = [] + let pending: ProcessSeat[] = [] + let pendingTurn: number | undefined + const flush = (closed: boolean): void => { + const [first, ...rest] = pending + if (first !== undefined) ranges.push({ + key: `${first.nodeKey}:process`, seats: [first, ...rest], process: true, closed, turn: pendingTurn, + }) + pending = [] + } + for (const key of snapshot.order) { + const node = snapshot.nodes.get(key) as ChatNode | undefined + if (node === undefined || !isVisibleChatNode(node)) continue + const location = node.location + const turn = location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined + if (pending.length > 0 && turn !== pendingTurn) flush(true) + pendingTurn = turn + if (INDEPENDENT.has(node.kind) || turn === undefined) { + // The synthetic whole-turn control must not split an ongoing process range. + if (node.kind === 'turn-process') { + ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn }) + continue + } + flush(true) + ranges.push({ key, seats: [{ nodeKey: key }], process: false, closed: true, turn }) + } else if (node.kind === 'assistant-step') { + if (node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')) { + pending.push({ nodeKey: key, assistantPart: 'reasoning' }) + } + if (hasAssistantReplyContent(node.data.blocks)) { + flush(true) + ranges.push({ key, seats: [{ nodeKey: key, assistantPart: 'response' }], process: false, closed: true, turn }) + } + } else { + pending.push({ nodeKey: key }) + } + } + const lastTurn = pendingTurn === undefined ? undefined : snapshot.timeline.turns.get(pendingTurn) + flush(lastTurn?.status === 'closed') + return ranges +} + + +function activity(name: string): ProcessActivity { + if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read' + if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search' + if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit' + if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands' + if (name === 'run_code') return 'code' + if (name === 'web_search') return 'webSearch' + if (name === 'web_fetch') return 'webFetch' + if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents' + if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan' + if (name === 'ask_user_question' || name === 'request_user_input') return 'questions' + return 'tools' +} + +const LIVE_TOOL_DETAIL_MAX_CHARS = 160 +const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +const LIVE_TOOL_DETAIL_KEYS = [ + 'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message', + 'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status', +] as const + +function normalizeLiveToolDetail(value: unknown): string { + const text = typeof value === 'string' + ? value + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value.join(', ') + : '' + const normalized = text.replace(/\s+/g, ' ').trim() + const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment) + return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS + ? normalized + : `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…` +} + +function questionDetail(value: unknown): string { + if (!Array.isArray(value)) return '' + for (const item of value) { + if (item === null || typeof item !== 'object') continue + const detail = normalizeLiveToolDetail(Reflect.get(item, 'question')) + if (detail !== '') return detail + } + return '' +} + +function liveReasoningDetail(nodes: readonly ChatNode[]): string { + for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) { + const node = nodes[nodeIndex] + if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue + for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) { + const block = node.data.blocks[blockIndex] + if (block?.kind !== 'reasoning') continue + const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/) + for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) { + const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', '')) + if (detail !== '') return detail + } + } + } + return '' +} + +function liveToolDetail(name: string, argsRaw: string): string { + let args: unknown + try { + args = JSON.parse(argsRaw) + } catch { + // Partial or free-form arguments have no safe one-line task detail. + return normalizeLiveToolDetail(name) + } + if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name) + for (const key of LIVE_TOOL_DETAIL_KEYS) { + if (key in args) { + const value: unknown = Reflect.get(args, key) + const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value) + if (detail !== '') return detail + } + } + return normalizeLiveToolDetail(name) +} + +/** + * Rank categories by distinct call count, breaking ties by first appearance. + * @param nodes - process members, including recursive tools. + * @returns all ranked categories and the latest running tool category and bounded task detail. + */ +export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySummary { + const counts = new Map() + const seen = new Set() + let running: ProcessActivity | undefined + let runningDetail = '' + let runningTime = -Infinity + const visit = (tool: ToolCallBlock): void => { + if (seen.has(tool.callId)) return + seen.add(tool.callId) + const call = isRunningTool(tool) ? tool : tool.call + if (call !== null) { + const kind = activity(call.name) + if (isRunningTool(tool) && tool.time >= runningTime) { + running = kind + runningDetail = liveToolDetail(tool.name, tool.argsRaw) + runningTime = tool.time + } + counts.set(kind, (counts.get(kind) ?? 0) + 1) + } + for (const child of tool.subCalls) visit(child) + } + for (const node of nodes) { + if (node.kind === 'tool-call') visit(node.data.root) + } + if (running === undefined) runningDetail = liveReasoningDetail(nodes) + return { + counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count), + running, + runningDetail, + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts index 331840e80c..d0f6c40798 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -1,3 +1,4 @@ +import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeStore, TurnNavigationItem } from '../contract/snapshot.ts' @@ -82,7 +83,7 @@ export function turnNavigationItem( ): TurnNavigationItem | undefined { const loaded = locations.getTurn(turn) .map(key => nodes.get(key)) - .filter((node): node is ChatNode => node !== undefined && node.visibility === 'visible') + .filter((node): node is ChatNode => node !== undefined && isVisibleChatNode(node as ChatNode)) const user = loaded.find(node => node.kind === 'user') const anchor = user ?? loaded[0] if (anchor === undefined) return undefined diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index a49ae6031b..f5426debb3 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -1,3 +1,4 @@ +import { isVisibleChatNode } from '../contract/chat-visibility.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatLocationNodeIndex, ChatNodeStore, ChatTurnProcessPresentation, @@ -16,6 +17,7 @@ function samePresentation( return left === right || (left !== undefined && right !== undefined && left.spec === right.spec && left.turn === right.turn + && left.turnStarted === right.turnStarted && left.turnClosed === right.turnClosed && left.hasExternalProcess === right.hasExternalProcess && left.compactAnswer === right.compactAnswer) @@ -38,8 +40,8 @@ function derivePresentation( let openingHumanAnchor: number | undefined for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined - if ((node?.kind === 'user' || node?.kind === 'steering') - && node.anchorSeq < spec.controlAnchorSeq) { + if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger') + && (spec.controlAnchorSeq === location.turn.start?.seq || node.anchorSeq < spec.controlAnchorSeq)) { openingHumanAnchor = Math.min(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq) } } @@ -48,8 +50,8 @@ function derivePresentation( let compactAnswer = true for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined - if (node === undefined || node.kind === 'turn-process') continue - if ((node.kind === 'user' || node.kind === 'steering') + if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue + if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger') && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor) && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { compactAnswer = false @@ -64,6 +66,7 @@ function derivePresentation( return { turn, spec, + turnStarted: location.turn.start !== undefined, turnClosed: location.turn.status === 'closed', hasExternalProcess, compactAnswer, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index 626f9199b2..69f8baa42c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -122,7 +122,7 @@ function latestAnswer(turn: TurnLocation): Readonly | nu } function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null { - const controlAnchorSeq = state.controlAnchorSeq + const controlAnchorSeq = state.controlAnchorSeq ?? turn.start?.seq if (controlAnchorSeq === undefined) return null const answer = latestAnswer(turn) const counts = { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 1cccf210f3..5f5766b149 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -3,14 +3,12 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client' import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -import { toAssistantBlocks } from './event-projection.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -31,29 +29,10 @@ interface TurnTailState { readonly end?: ConversationMatch } -interface StepEvidence { - readonly streamedText: boolean - readonly finalized: boolean -} - function isSessionEvent(event: ConversationMatch['event']): event is SessionEvent { return event.type !== 'assistant/live-chunk' } -function hasTextAssistant(event: Parameters[0]): boolean { - return event.type === 'assistant/message' - && event.surfaceOp === 'append' - && toAssistantBlocks(event.data.message.content) - .some(block => block.kind === 'text' && block.text.trim() !== '') -} - -function chunkHasText(chunk: StreamChunk): boolean { - if (chunk.type === 'text-delta') return chunk.text.trim() !== '' - return chunk.type === 'block-end' - && chunk.block.type === 'text' - && chunk.block.text.trim() !== '' -} - function turnCoordinates(event: Parameters[0]): { readonly turn: number readonly step?: number @@ -71,43 +50,6 @@ function turnCoordinates(event: Parameters[ return undefined } -function closingAnchor(context: ConversationNodeContext): number { - let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq - ?? context.start?.event.seq - ?? context.matches[0]?.event.seq - ?? 0 - const steps = new Map() - for (const match of context.matches) { - const event = match.event - if (event.type === 'turn/end') continue - const coordinates = turnCoordinates(event) - if (coordinates?.step === undefined) continue - const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false } - if (event.type === 'assistant/live-chunk') { - steps.set(coordinates.step, { - ...previous, - streamedText: previous.streamedText || chunkHasText(event.data.chunk), - }) - continue - } - if (event.type === 'assistant/message') { - steps.set(coordinates.step, { streamedText: false, finalized: true }) - if (hasTextAssistant(event)) { - anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup - } - continue - } - if (event.type === 'llm/retry') { - steps.set(coordinates.step, { streamedText: false, finalized: false }) - continue - } - if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { - anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup - } - } - return anchor -} - function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { const location = context.start?.location ?? context.matches[0]?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined @@ -193,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') - return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) + return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data) }, } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 344fef13d0..82ef8145fe 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -5,6 +5,34 @@ export const NS = 'chat' /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { + 'message.stepProcess.thinking': '正在分析请求', + 'message.stepProcess.read': '正在读取文件', + 'message.stepProcess.search': '正在搜索代码', + 'message.stepProcess.edit': '正在编辑文件', + 'message.stepProcess.commands': '正在运行命令', + 'message.stepProcess.code': '正在运行代码', + 'message.stepProcess.webSearch': '正在搜索网页', + 'message.stepProcess.webFetch': '正在访问网页', + 'message.stepProcess.subagents': '正在协调子任务', + 'message.stepProcess.plan': '正在更新计划', + 'message.stepProcess.questions': '等待你的操作', + 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.done.thinking': '已完成分析', + 'message.stepProcess.done.read': '已读取文件', + 'message.stepProcess.done.search': '已搜索代码', + 'message.stepProcess.done.edit': '修改了文件', + 'message.stepProcess.done.commands': '执行了命令', + 'message.stepProcess.done.code': '运行了代码', + 'message.stepProcess.done.webSearch': '已搜索网页', + 'message.stepProcess.done.webFetch': '已访问网页', + 'message.stepProcess.done.subagents': '已协调子任务', + 'message.stepProcess.done.plan': '更新了计划', + 'message.stepProcess.done.questions': '向用户提出了问题', + 'message.stepProcess.done.tools': '已调用工具', + 'message.stepProcess.joinTwo': '{first}并{second}', + 'message.stepProcess.comma': ',', + 'message.stepProcess.sharedPrefix': '已', + 'message.stepProcess.more': '{title}等', 'view.chat': '对话', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}秒', @@ -22,7 +50,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', - 'chat.deepDiving': '深度求索中...', + 'chat.deepDiving': '深度求索中', 'chat.turnNavigation.label': '轮次导航', 'chat.turnNavigation.jump': '跳转到第 {turn} 轮', 'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮', @@ -31,29 +59,16 @@ export const zh = { 'settings.performance.description': '选择性能与用量信息展示的详细程度', 'settings.performance.compact': '简洁', 'settings.performance.detailed': '详细', - 'settings.transcript.title': '对话显示', - 'settings.transcript.description': '控制已完成轮次的过程内容', - 'settings.transcript.normal': '标准', - 'settings.transcript.compact': '紧凑', + 'settings.transcript.title': '工作过程展示', + 'settings.transcript.description': '控制轮次和步骤的默认展开方式', + 'settings.transcript.detailed': '详细', + 'settings.transcript.expanded': '完全展开', + 'settings.transcript.compact': '简洁', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', - 'message.systemPrompt': '系统提示词', - 'message.systemPromptUpdate': '系统提示词更新', - 'message.contextInjection': '上下文注入', - 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', 'message.referenceSeparator': '、', - 'message.context.instructions.loaded': '已载入', - 'message.context.instructions.added': '已新增', - 'message.context.instructions.updated': '已更新', - 'message.context.instructions.removed': '已移除', - 'message.context.catalog.replaced': '替换目录', - 'message.context.catalog.more': '…还有 {count} 条', - 'message.context.snapshot.supersedes': '取代先前的快照', - 'message.context.relay.from': '来自会话 {session}', - 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', - 'message.context.recall.truncated': '已截断', 'message.compaction': '上下文已压缩', 'message.compaction.running': '正在压缩…', 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', @@ -63,13 +78,21 @@ export const zh = { 'message.think': '思考', 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', - 'message.turnProcess.toolCalls.one': '{count} 次工具调用', - 'message.turnProcess.toolCalls.other': '{count} 次工具调用', - 'message.turnProcess.messages.one': '{count} 条消息', - 'message.turnProcess.messages.other': '{count} 条消息', - 'message.turnProcess.subagents.one': '{count} 个 subagent', - 'message.turnProcess.subagents.other': '{count} 个 subagent', - 'message.turnProcess.thoughtForAWhile': '已思考', + 'message.trigger.request': '收到执行请求', + 'message.trigger.goal': '继续执行目标', + 'message.trigger.agent': '收到任务消息', + 'message.trigger.team': '收到团队消息', + 'message.trigger.subagent': '子任务状态更新', + 'message.trigger.github': '收到 GitHub 事件', + 'message.trigger.webhook': '收到外部事件', + 'message.trigger.schedule': '定时任务', + 'message.trigger.job': '后台任务状态更新', + 'message.trigger.plugin': '插件状态更新', + 'message.trigger.explanation': '这条通知触发了本轮回复。', + 'message.turnProcess.worked': '已完成工作', + 'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}', + 'message.turnProcess.took': '用时 {duration}', + 'message.turnProcess.failed': '处理失败', 'message.turnProcess.separator': ' · ', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', @@ -115,6 +138,34 @@ export type ChatKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { + 'message.stepProcess.thinking': 'Analyzing the request', + 'message.stepProcess.read': 'Reading files', + 'message.stepProcess.search': 'Searching code', + 'message.stepProcess.edit': 'Editing files', + 'message.stepProcess.commands': 'Running commands', + 'message.stepProcess.code': 'Running code', + 'message.stepProcess.webSearch': 'Searching the web', + 'message.stepProcess.webFetch': 'Visiting web pages', + 'message.stepProcess.subagents': 'Coordinating subagents', + 'message.stepProcess.plan': 'Updating the plan', + 'message.stepProcess.questions': 'Waiting for your action', + 'message.stepProcess.tools': 'Calling tools', + 'message.stepProcess.done.thinking': 'Analysis completed', + 'message.stepProcess.done.read': 'Read files', + 'message.stepProcess.done.search': 'Searched code', + 'message.stepProcess.done.edit': 'Edited files', + 'message.stepProcess.done.commands': 'Ran commands', + 'message.stepProcess.done.code': 'Ran code', + 'message.stepProcess.done.webSearch': 'Searched the web', + 'message.stepProcess.done.webFetch': 'Visited web pages', + 'message.stepProcess.done.subagents': 'Coordinated subagents', + 'message.stepProcess.done.plan': 'Updated the plan', + 'message.stepProcess.done.questions': 'Asked questions', + 'message.stepProcess.done.tools': 'Called tools', + 'message.stepProcess.joinTwo': '{first} and {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}, etc.', 'view.chat': 'Chat', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}s', @@ -141,29 +192,16 @@ export const en = { 'settings.performance.description': 'Choose how much performance and usage information to show', 'settings.performance.compact': 'Compact', 'settings.performance.detailed': 'Detailed', - 'settings.transcript.title': 'Conversation display', - 'settings.transcript.description': 'Controls process content in completed turns', - 'settings.transcript.normal': 'Normal', + 'settings.transcript.title': 'Work details', + 'settings.transcript.description': 'Controls how turns and steps expand by default', + 'settings.transcript.detailed': 'Detailed', + 'settings.transcript.expanded': 'Expanded', 'settings.transcript.compact': 'Compact', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', - 'message.systemPrompt': 'System prompt', - 'message.systemPromptUpdate': 'System prompt update', - 'message.contextInjection': 'Context injection', - 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', 'message.referenceSeparator': ', ', - 'message.context.instructions.loaded': 'loaded', - 'message.context.instructions.added': 'added', - 'message.context.instructions.updated': 'updated', - 'message.context.instructions.removed': 'removed', - 'message.context.catalog.replaced': 'Replacement catalog', - 'message.context.catalog.more': '… {count} more', - 'message.context.snapshot.supersedes': 'Supersedes earlier snapshots', - 'message.context.relay.from': 'From session {session}', - 'message.context.recall.counts': '{retained} kept · {omitted} omitted', - 'message.context.recall.truncated': 'truncated', 'message.compaction': 'Context compacted', 'message.compaction.running': 'Compacting context…', 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', @@ -173,13 +211,21 @@ export const en = { 'message.think': 'Think', 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', - 'message.turnProcess.toolCalls.one': '{count} tool call', - 'message.turnProcess.toolCalls.other': '{count} tool calls', - 'message.turnProcess.messages.one': '{count} message', - 'message.turnProcess.messages.other': '{count} messages', - 'message.turnProcess.subagents.one': '{count} subagent', - 'message.turnProcess.subagents.other': '{count} subagents', - 'message.turnProcess.thoughtForAWhile': 'Thought for a while', + 'message.trigger.request': 'Execution requested', + 'message.trigger.goal': 'Continuing goal', + 'message.trigger.agent': 'Task message received', + 'message.trigger.team': 'Team message received', + 'message.trigger.subagent': 'Subtask status updated', + 'message.trigger.github': 'GitHub event received', + 'message.trigger.webhook': 'External event received', + 'message.trigger.schedule': 'Scheduled task', + 'message.trigger.job': 'Background task updated', + 'message.trigger.plugin': 'Plugin status updated', + 'message.trigger.explanation': 'This notification triggered this response.', + 'message.turnProcess.worked': 'Worked', + 'message.turnProcess.deepDivingFor': 'Deep diving for {duration}', + 'message.turnProcess.took': 'Took {duration}', + 'message.turnProcess.failed': 'Failed', 'message.turnProcess.separator': ' · ', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index 7dc7df7363..f45eb0a1d3 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -1,4 +1,4 @@ -/** General Settings row for completed-Turn transcript presentation. */ +/** General Settings row for work details presentation. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -12,7 +12,7 @@ export interface TranscriptViewRowInjected { /** Persisted transcript preference bound as useTranscriptView. */ transcriptView: SnapshotStore } - /** Change the completed-Turn transcript presentation. */ + /** Change the work details presentation. */ setTranscriptView: (mode: TranscriptViewMode) => void } @@ -22,10 +22,13 @@ export type TranscriptViewRowProps = & PropsLocale<'chat'> & InjectFace -const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ - { id: 'normal', label: 'settings.transcript.normal' }, - { id: 'compact', label: 'settings.transcript.compact' }, -] +const LABELS = { + compact: 'settings.transcript.compact', + detailed: 'settings.transcript.detailed', + expanded: 'settings.transcript.expanded', +} as const satisfies Record + +const OPTIONS: readonly TranscriptViewMode[] = ['compact', 'detailed', 'expanded'] /** * Render the completed-Turn transcript mode selector. @@ -34,16 +37,13 @@ const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [ */ export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) { const mode = useTranscriptView(value => value) - const selectedLabel = mode === 'normal' - ? 'settings.transcript.normal' - : 'settings.transcript.compact' return ( ({ id: option.id, label: t(option.label) }))} + selectedLabel={t(LABELS[mode])} + options={OPTIONS.map(id => ({ id, label: t(LABELS[id]) }))} onSelect={(value) => { setTranscriptView(value as TranscriptViewMode) }} /> ) diff --git a/packages/client/ui-chat/src/client/stores.ts b/packages/client/ui-chat/src/client/stores.ts index afb99e2f5d..b068a1b5a8 100644 --- a/packages/client/ui-chat/src/client/stores.ts +++ b/packages/client/ui-chat/src/client/stores.ts @@ -12,7 +12,7 @@ type ChatActions = { } /** - * Resolve the manually expanded answer for one Turn. + * Resolve the manual disclosure override for one Turn. * @param state - Chat store snapshot. * @param turn - owning Turn. * @returns the Turn's stored entry, when present. diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts index 5534a0139c..4ee2be8215 100644 --- a/packages/client/ui-chat/src/client/transcript-view.ts +++ b/packages/client/ui-chat/src/client/transcript-view.ts @@ -1,4 +1,4 @@ -/** Host-backed completed-Turn transcript presentation policy. */ +/** Host-backed work details presentation policy. */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -22,7 +22,7 @@ export class TranscriptViewPolicy { /** * Publish and persist one explicit user choice. - * @param mode - Normal or Compact transcript presentation. + * @param mode - Compact, Detailed, or Expanded work details. */ setMode(mode: TranscriptViewMode): void { if (this.mode.getSnapshot() === mode) return @@ -33,7 +33,8 @@ export class TranscriptViewPolicy { /** Adopt the latest accepted Host section without writing it back. */ private adopt(): void { const section = this.host.getSnapshot().value - if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return - this.mode.set(section.transcriptView) + if (section === undefined) return + const mode = section.transcriptView === 'normal' ? 'detailed' : section.transcriptView + if (this.mode.getSnapshot() !== mode) this.mode.set(mode) } } diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 92290ff074..4d3cb425f6 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -105,9 +105,9 @@ describe('Chat apply wiring', () => { const face = (row.inject as unknown as () => TranscriptViewRowInjected)() expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') - face.setTranscriptView('normal') - expect(face.hooks.transcriptView.getSnapshot()).toBe('normal') - expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') + face.setTranscriptView('expanded') + expect(face.hooks.transcriptView.getSnapshot()).toBe('expanded') + expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'expanded') b.chatSettings.publish({ status: 'ready', value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 4e2493d17c..22d52599dd 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -12,7 +12,7 @@ import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' import { - CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, + CompactionNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' @@ -71,8 +71,6 @@ function MessageItem({ node, t: translate, referenceLabels, skillNames }: Messag case 'user': case 'steering': return } /> - case 'context': - return } /> case 'compaction': return } /> case 'model-retry': @@ -325,482 +323,6 @@ describe('MessageItem arms', () => { expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() }) - it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => { - const ctxView = render( - , - ) - const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ }) - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() - expect(ctxView.container.querySelector('svg')).not.toBeNull() - expect(ctxView.container.querySelector('[data-context-recall-icon]')).toBeNull() - - fireEvent.click(disclosure) - expect(disclosure.getAttribute('aria-expanded')).toBe('true') - // An unknown form renders the opaque body: the model-facing text keeps its - // real line breaks instead of being escaped into one JSON line, and the - // remaining source data follows it as fields. - expect(ctxView.container.querySelector('[data-context-text]')?.textContent) - .toBe('line one\n\nline two') - const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) - expect(fields).toEqual(['plugin', 'empty', 'list']) - - fireEvent.keyDown(disclosure, { key: ' ' }) - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - }) - - it('the instructions form names the files it reconciled above their text', () => { - const view = render( - \nInstructions from: AGENTS.md\n' }], - source: { - kind: 'agent-instructions', - form: 'instructions', - baseline: true, - changes: [ - { action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' }, - { action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' }, - { action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' }, - ], - }, - producer: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' }, - form: 'instructions', - } as never} - />, - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ })) - const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) - expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除']) - // The `` framing is part of what the model read, so the - // body keeps it verbatim rather than presenting a cleaned-up excerpt. - expect(view.container.querySelector('[data-context-text]')?.textContent) - .toContain('') - }) - - it('a delta distinguishes a newly reconciled file from a rewritten one', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ })) - const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) - expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新']) - }) - - it('keeps an interleaved unknown block in the order the model received it', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: '上下文注入' })) - const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent) - expect(texts).toEqual(['before', 'after']) - expect(view.getByText(/未知内容块/)).toBeTruthy() - }) - - it('the catalog form lists its durable entries instead of the model-facing prose', () => { - const view = render( - \n\n- `a`: A\n' }], - source: { - kind: 'skill-catalog', - form: 'catalog', - entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }], - }, - producer: { role: 'inject', label: 'skill-catalog' }, - form: 'catalog', - } as never} - />, - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent) - expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B']) - expect(view.container.querySelector('[data-context-text]')).toBeNull() - expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull() - }) - - it('a replacement catalog says so above its entries', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') - }) - - it('a partially unreadable catalog falls back whole rather than showing a short list', () => { - // All-or-nothing: a body that replaces the model-facing text must not show - // a confident, incomplete account of what the model read. - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.container.querySelector('[data-context-entries]')).toBeNull() - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') - // The marker reports what rendered, not what was declared. - expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) - .toBeNull() - }) - - it('an unreadable instruction list falls back to the opaque body with its fields', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-instructions$/ })) - expect(view.container.querySelector('[data-context-files]')).toBeNull() - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') - expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() - }) - - it('joins adjacent text blocks the way a provider adapter flattens them', () => { - // No invented separator: showing a line break the model never saw would - // misreport the request. - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: '上下文注入' })) - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond') - }) - - it('bounds an oversized source field, not only the model-facing text', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) - expect(view.container.querySelector('[data-context-fields] dd')?.textContent) - .toMatch(/… 已截断,共 \d+ 字符$/) - }) - - it('an empty replacement catalog stays a catalog: it retires every earlier name', () => { - // `renderCatalogUpdate` legitimately publishes zero entries when the last - // skill disappears; falling back would hide that the catalog was cleared. - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录') - expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0) - expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) - .toBe('catalog') - }) - - it('a catalog whose entries are unreadable falls back to the opaque body', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.container.querySelector('[data-context-entries]')).toBeNull() - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose') - }) - - it('bounds a large catalog and says how many rows it withheld', () => { - const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' })) - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200) - expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条') - }) - - it('a catalog keeps a content block this version does not know', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ })) - expect(view.getByText(/未知内容块/)).toBeTruthy() - }) - - it('an instruction change with an unrecognized action falls back whole', () => { - // The action decides the word the row shows, so an unknown one cannot be - // presented as loaded or updated. - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-instructions$/ })) - expect(view.container.querySelector('[data-context-files]')).toBeNull() - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose') - }) - - it('the opaque fallback keeps a form declaration this version cannot present', () => { - // Otherwise a newer or foreign log's declared shape vanishes from the UI. - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ })) - const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent) - expect(fields).toEqual(['plugin', 'form']) - }) - - it('the snapshot form attributes each part to the subsystem that produced it', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ })) - const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent) - expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo']) - }) - - it('a notice puts its account on the collapsed row', () => { - // The whole point of the form: readable without expanding. - const view = render( - , - ) - expect(view.container.querySelector('[data-context-summary]')?.textContent) - .toBe('bash pnpm test [status: completed]') - expect(view.container.querySelector('[data-context-injection-body]')).toBeNull() - }) - - it('a notice without its account falls back to the opaque body', () => { - const view = render( - , - ) - expect(view.container.querySelector('[data-context-summary]')).toBeNull() - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-jobs$/ })) - expect(view.container.querySelector('[data-context-fields]')).not.toBeNull() - }) - - it('each form falls back to the opaque body when its required facts are unreadable', () => { - // The fallback chain is the load-bearing wall: every dedicated form must - // reach it, and the row marker must not claim a form that did not render. - const cases = [ - { form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' }, - { form: 'relay', source: { kind: 'agent-message', form: 'relay' }, label: 'agent-message' }, - { form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' }, - ] as const - for (const { form, source, label } of cases) { - cleanup() - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) })) - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`) - expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form')) - .toBeNull() - } - }) - - it('a snapshot states the supersession its framing line carries', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ })) - expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent) - .toBe('取代先前的快照') - }) - - it('a relay names the agent that sent it above what it said', () => { - const view = render( - , - ) - fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*agent-message$/ })) - expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7') - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body') - }) - - it('a recall reports how much of each source session survived the read', () => { - // Recalled context is bounded on the way in, so hiding the omitted count - // would overstate what the model received. - const view = render( - , - ) - expect(view.container.querySelector('[data-context-recall-icon]')).not.toBeNull() - fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ })) - const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent) - expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条']) - expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material') - }) - it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , @@ -1042,7 +564,7 @@ describe('useCalendarDay boundary refresh', () => { }) describe('small branch tails', () => { - it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { + it('AssistantMarkdown hides settled single-line reasoning until expanded', () => { const view = render( { renderMessageImages={renderMessageImages} />, ) + expect(view.queryByText('one-liner')).toBeNull() + fireEvent.click(view.getByText('思考')) expect(view.getByText('one-liner')).toBeTruthy() }) diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index 4edce88336..87cd9fe649 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -19,6 +19,19 @@ function declarationsFrom(source: string, selector: string): string[] { } describe('chat flow font-size axis', () => { + it('process titles follow their body and secondary font-size tiers', () => { + expect(declarationsFrom(read('StepProcessList.module.css'), '.title')).toContain( + 'font-size: var(--dsh-content-font-size, 14px)', + ) + expect(declarationsFrom(read('TurnProcessNodeView.module.css'), '.label')).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', + ])) + expect(declarationsFrom(read('TurnProcessNodeView.module.css'), '.root')).toContain( + 'height: calc(33px + var(--dsh-content-font-delta, 0px))', + ) + }) + it('think text reads the secondary tier (one step under the body size)', () => { const css = read('ReasoningRow.module.css') expect(declarationsFrom(css, '.summary')).toEqual(expect.arrayContaining([ @@ -32,18 +45,24 @@ describe('chat flow font-size axis', () => { ])) }) - it('command and context summaries read the secondary tier on the shared row line', () => { + it('starts streaming think summaries at the left edge and fades their right edge', () => { + const css = read('ReasoningRow.module.css') + expect(declarationsFrom(css, '.summary[data-streaming]')).toContain( + 'mask-image: linear-gradient(to right, black calc(100% - 48px), transparent)', + ) + expect(declarationsFrom(css, '.summary[data-streaming] .summaryText')).toEqual(expect.arrayContaining([ + 'overflow: visible', + 'text-overflow: clip', + ])) + expect(css).not.toContain('justify-content: flex-end') + }) + + it('command summaries read the secondary tier on the shared row line', () => { expect(declarationsFrom(read('GenericCommandCard.module.css'), '.summary')).toEqual(expect.arrayContaining([ 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) - const context = read('ContextInjectionRow.module.css') - for (const selector of ['.source', '.summary']) { - expect(declarationsFrom(context, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', - 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', - ])) - } + }) it('the message clock and action glyphs scale with the text they serve', () => { @@ -84,8 +103,6 @@ describe('chat flow font-size axis', () => { .toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`])) expect(declarationsFrom(read('MessageItem.module.css'), '.compactionBody')) .toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`])) - expect(declarationsFrom(read('ContextInjectionRow.module.css'), '.body')) - .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`])) }) it('the usage-details trigger reads the secondary tier like its clock label', () => { diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 21b0d483ff..f6a0640057 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,3 +1,4 @@ +import { ChatStepProcessProjector } from '../src/client/conversation-nodes/step-process-index.ts' import type { AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatNode, ChatSnapshot, ConversationNode, ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, @@ -304,7 +305,7 @@ export function chatSnapshotFixture(input: { type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn, } as never, end: timing?.endTime === undefined || endSeq === undefined ? undefined : { - type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed', + type: 'turn/end', seq: endSeq, time: timing.endTime, data: { turn, reason: { kind: 'completed' } }, } as never, status: endSeq === undefined ? 'open' : 'closed', steps: EMPTY, @@ -401,14 +402,15 @@ export function chatSnapshotFixture(input: { const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step' || candidate.kind === 'tool-call' || candidate.kind === 'model-retry') - if (controlAnchor === undefined) continue + const startSeq = turns.get(turnNumber)?.start?.seq + if (controlAnchor === undefined && startSeq === undefined) continue const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind)) ?? controlAnchor const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true const candidate: TurnProcessSpec = { turn: turnNumber, - controlAnchorSeq: controlAnchor.anchorSeq, - processStartSeq: processStart.anchorSeq, + controlAnchorSeq: controlAnchor?.anchorSeq ?? startSeq!, + processStartSeq: processStart?.anchorSeq ?? startSeq!, answerAnchorSeq: answer?.finalNode.seq ?? null, answerStep: answer?.step ?? null, inlineReasoning: answer !== undefined && inlineReasoning, @@ -516,9 +518,13 @@ export function chatSnapshotFixture(input: { && derived.every((item, index) => sameTurnNavigationItem(kept[index], item)) ? kept : derived + const stepProcesses = previous?.stepProcesses instanceof ChatStepProcessProjector + ? previous.stepProcesses : new ChatStepProcessProjector() + stepProcesses.replace({ order, nodes: store, timeline }) for (const data of turnData.values()) data.publish() store.publish() return { + stepProcesses, order, nodes: store, locations, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index a25692a2d5..91f4973078 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -35,13 +35,12 @@ import { en, zh } from '../src/client/locale.ts' import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { - CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + CompactionNodeView, RetryNodeView, TurnErrorNodeView, TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx' -import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' -import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { formatLiveRunDuration, formatRunDuration } from '../src/client/chat/message-chrome.ts' import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -307,7 +306,7 @@ function makeHarness( case 'steering': return ()} /> case 'context': - return ()} /> + throw new Error('hidden context reached the Chat renderer') case 'assistant-step': return ()} /> case 'command': @@ -331,7 +330,7 @@ function makeHarness( case 'turn-process': return ()} /> case 'system-prompt': - return ()} /> + throw new Error('hidden system prompt reached the Chat renderer') case 'turn-tail': return ( { expect(formatRunDuration(3_903_000, t)).toBe('1h 05m 03s') }) + it('formatLiveRunDuration leaves seconds unpadded and rolls sixty minutes into hours', () => { + const t = makeTranslate(zh, commonZh) + expect(formatLiveRunDuration(0, t)).toBe('0秒') + expect(formatLiveRunDuration(15_999, t)).toBe('15秒') + expect(formatLiveRunDuration(125_399, t)).toBe('2分5秒') + expect(formatLiveRunDuration(3_599_999, t)).toBe('59分59秒') + expect(formatLiveRunDuration(3_600_000, t)).toBe('1小时00分0秒') + expect(formatLiveRunDuration(3_903_199, t)).toBe('1小时05分3秒') + }) + }) describe('ChatView', () => { + it.each([ + [0, 'Took 1s'], + [250, 'Took 1s'], + [999, 'Took 1s'], + [4_000, 'Took 4s'], + [65_000, 'Took 1m 05s'], + [3_665_000, 'Took 1h 01m 05s'], + ])('labels the whole turn with its logged duration (%i ms)', (duration, label) => { + const h = makeHarness({ + nodes: [user(1, 'question'), reasoningAssistant(2, 'analysis'), assistant(3, 'answer', 1, 2)], + turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 10_000, endTime: 10_000 + duration }]]), + }) + h.props.t = makeTranslate(en, commonEn) + const view = render() + const toggle = view.getByRole('button', { name: label }) + fireEvent.click(toggle) + expect(toggle.textContent).toBe(label) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('keeps the elapsed label without a disclosure when a turn contains only its answer', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(3, 'answer', 1, 1)], + turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 10_000, endTime: 10_000 }]]), + }) + h.props.t = makeTranslate(en, commonEn) + const view = render() + const label = view.getByRole('button', { name: 'Took 1s' }) as HTMLButtonElement + expect(label.disabled).toBe(true) + expect(label.hasAttribute('aria-expanded')).toBe(false) + expect(label.querySelector('svg')).toBeNull() + expect(view.getByText('answer')).toBeTruthy() + }) + + it.each([ + [{ kind: 'aborted', reason: { kind: 'user' } }, 'Stopped', '已停止'], + [{ kind: 'error', error: { code: 'UNKNOWN', message: 'failed' } }, 'Failed', '处理失败'], + ] as const)('keeps %s turns expanded with a disabled title', (reason, english, chinese) => { + const snapshot = chatSnapshotFixture({ + nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')], + turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 10_000, endTime: 12_000 }]]), + }) + Object.assign(snapshot.timeline.turns.get(1)!.end!, { data: { turn: 1, reason } }) + const h = makeHarness({ chat: snapshot }) + const view = render() + const toggle = view.getByRole('button', { name: chinese }) as HTMLButtonElement + expect(toggle.disabled).toBe(true) + expect(toggle.querySelector('svg')).toBeNull() + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-step-process]')?.hasAttribute('hidden')).toBe(false) + h.props.t = makeTranslate(en, commonEn) + view.rerender() + expect(view.getByRole('button', { name: english })).toBe(toggle) + }) + + it.each([ + ['bash', 'Ran commands', 'Ran commands'], + ['read', 'Read files', 'Read files'], + ['edit', 'Edited files', 'Edited files'], + ['grep', 'Searched code', 'Searched code'], + ['custom_tool', 'Called tools', 'Called tools'], + ])('omits call counts from completed %s activity in English', (name, singular, plural) => { + const opening = assistant(2, 'Starting.', 2) + const result = toolResult(3, 'first', name) + const response = assistant(5, 'Progress.', 2, 2) + const h = makeHarness({ nodes: [userInTurn(1, 'inspect', 2), opening, result, response] }) + h.props.t = makeTranslate(en, commonEn) + const view = render() + expect(view.getByRole('button', { name: singular })).toBeTruthy() + act(() => { h.setChat({ nodes: [userInTurn(1, 'inspect', 2), opening, result, toolResult(4, 'second', name), response] }) }) + expect(view.getByRole('button', { name: plural })).toBeTruthy() + }) + + it.each([ + ['bash', { command: 'git status', description: '检查当前改动' }, '正在运行命令 · 检查当前改动'], + ['web_search', { queries: ['Cordis plugins', 'DSH tools'] }, '正在搜索网页 · Cordis plugins, DSH tools'], + ['web_fetch', { url: 'https://example.com/docs' }, '正在访问网页 · https://example.com/docs'], + ['read', { file_path: '/src/main.ts' }, '正在读取文件 · /src/main.ts'], + ['custom_tool', {}, '正在调用工具 · custom_tool'], + ])('shows live %s details only in Detailed secondary titles', (name, args, expected) => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + try { + const h = makeHarness({ + nodes: [userInTurn(1, 'inspect', 2), reasoningAssistant(2, 'private reasoning', 2)], + runningCalls: [{ ...runningCall('work', name), argsRaw: JSON.stringify(args) }], + running: true, + }) + const view = render() + const title = () => view.container.querySelector('[data-step-process] > button')?.textContent + const abstract = title() + act(() => { h.setTranscriptView('detailed') }) + expect(title()).toBe(expected) + expect(title()).not.toContain('private reasoning') + act(() => { h.setTranscriptView('expanded') }) + expect(title()).toBeUndefined() + act(() => { h.setTranscriptView('compact') }) + expect(title()).toBe(abstract) + act(() => { + h.setTranscriptView('detailed') + h.setChat({ runningCalls: [], partial: { turn: 2, step: 2, blocks: [ + { kind: 'reasoning', text: 'private reasoning' }, + ] } }) + }) + expect(title()).toBe(expected) + act(() => { vi.advanceTimersByTime(150) }) + expect(title()).toBe('正在分析请求 · private reasoning') + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it('keeps each live tool title for 150ms and skips intermediate tools', () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + try { + const reasoning = reasoningAssistant(2, 'private reasoning', 2) + const h = makeHarness({ + nodes: [userInTurn(1, 'inspect', 2), reasoning], + runningCalls: [runningCall('first', 'bash')], + running: true, + }) + const view = render() + expect(view.getByRole('button', { name: '正在运行命令' })).toBeTruthy() + + act(() => { vi.advanceTimersByTime(50) }) + act(() => { h.setChat({ + nodes: [userInTurn(1, 'inspect', 2), reasoning, toolResult(3, 'first', 'bash')], + runningCalls: [runningCall('second', 'read')], + }) }) + act(() => { vi.advanceTimersByTime(50) }) + act(() => { h.setChat({ + nodes: [ + userInTurn(1, 'inspect', 2), reasoning, + toolResult(3, 'first', 'bash'), toolResult(4, 'second', 'read'), + ], + runningCalls: [runningCall('third', 'web_search')], + }) }) + + act(() => { vi.advanceTimersByTime(49) }) + expect(view.getByRole('button', { name: '正在运行命令' })).toBeTruthy() + expect(view.queryByRole('button', { name: '正在读取文件' })).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(view.getByRole('button', { name: '正在搜索网页' })).toBeTruthy() + expect(view.queryByRole('button', { name: '正在读取文件' })).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it('closes a work range on streamed response text and keeps its expansion while more work runs', () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + try { + const h = makeHarness({ + nodes: [userInTurn(1, 'inspect', 2), context(2, 'private runtime context', 2), reasoningAssistant(2.5, 'initial reasoning', 2)], + runningCalls: [runningCall('work')], + running: true, + }) + const view = render() + const toggle = view.getByRole('button', { name: '正在运行命令' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.getAttribute('data-process-activity')).toBe('commands') + expect(toggle.querySelector('[data-step-process-icon]')).not.toBeNull() + expect(toggle.querySelector('[data-step-process-chevron]')).not.toBeNull() + expect(toggle.querySelector('[data-text-shimmer]')).not.toBeNull() + const body = view.container.querySelector('[data-step-process-body]')! + expect(body.getAttribute('hidden')).toBe('until-found') + expect(toggle.textContent).not.toMatch(/context|上下文|permission|系统提示/) + fireEvent.click(toggle) + expect(body.hasAttribute('hidden')).toBe(false) + expect(toggle.querySelector('[data-step-process-chevron] path')?.getAttribute('d')) + .toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10') + act(() => { h.setChat({ + nodes: [userInTurn(1, 'inspect', 2), context(2, 'private runtime context', 2), reasoningAssistant(2.5, 'initial reasoning', 2), toolResult(3, 'work')], + runningCalls: [], + partial: { turn: 2, step: 2, blocks: [{ kind: 'reasoning', text: 'private analysis' }] }, + }) }) + expect(view.getByRole('button', { name: '正在运行命令' }).getAttribute('aria-expanded')).toBe('true') + act(() => { vi.advanceTimersByTime(150) }) + expect(view.getByRole('button', { name: '正在分析请求' }).getAttribute('aria-expanded')).toBe('true') + act(() => { h.setChat({ + partial: { turn: 2, step: 2, blocks: [ + { kind: 'reasoning', text: 'private analysis' }, { kind: 'text', text: 'Here is the progress.' }, + ] }, + }) }) + const completed = view.getByRole('button', { name: '执行了命令' }) + expect(completed.getAttribute('aria-expanded')).toBe('true') + expect(completed.getAttribute('data-process-activity')).toBe('commands') + expect(completed.querySelector('[data-text-shimmer]')).toBeNull() + expect(view.getByText('Here is the progress.').closest('[data-step-process-body]')).toBeNull() + expect(view.queryByText('private analysis')).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { const snapshot = chatSnapshotFixture({ nodes: [ @@ -997,7 +1210,7 @@ describe('ChatView', () => { expect(view.getByText('running tools')).toBeTruthy() expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a') expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b') - expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({ + expect([...view.container.querySelectorAll('[data-chat-flow-kind]')].map(row => ({ key: row.getAttribute('data-chat-flow-key'), kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ @@ -1012,7 +1225,7 @@ describe('ChatView', () => { expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) .toEqual([ 'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2', - 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', + 'fixture:tool:a:process', 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', ]) }) @@ -1051,7 +1264,9 @@ describe('ChatView', () => { fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() - expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now')) + const liveTurn = view.container.querySelector('[data-turn-process]') + expect(liveTurn).not.toBeNull() + expect((liveTurn as HTMLElement).compareDocumentPosition(view.getByText('interrupt now')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) act(() => { @@ -1313,14 +1528,14 @@ describe('ChatView', () => { const view = render() const disclosure = view.container.querySelector('details') as HTMLDetailsElement expect(disclosure.dataset.active).toBe('true') - expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + expect(within(disclosure).getByRole('status', { hidden: true }).textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { h.setChat({ nodes: [user(1, 'try'), nextRetry] }) }) - expect(within(disclosure).getAllByRole('status')).toHaveLength(1) + expect(within(disclosure).getAllByRole('status', { hidden: true })).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) - expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') + expect(within(disclosure).getByRole('status', { hidden: true }).textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { h.setChat({ @@ -1334,7 +1549,7 @@ describe('ChatView', () => { h.setSession({ running: false }) }) expect(disclosure.dataset.active).toBeUndefined() - expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') + expect(within(disclosure).getByRole('status', { hidden: true }).textContent).toBe('已重试模型请求(2/2) · 1s') act(() => { h.setChat({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }] }) @@ -1342,7 +1557,7 @@ describe('ChatView', () => { }) const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement expect(cancelledDisclosure.dataset.active).toBeUndefined() - expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消') + expect(within(cancelledDisclosure).getByRole('status', { hidden: true }).textContent).toContain('重试已取消') }) it('renders terminal turn failures inline with their durable message and optional code', () => { @@ -1360,6 +1575,7 @@ describe('ChatView', () => { const view = render() const statuses = view.getAllByRole('status') expect(statuses.map(status => status.textContent)).toEqual([ + '深度求索中', '已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', ]) expect(view.queryByText('本轮运行失败')).toBeNull() @@ -1423,145 +1639,82 @@ describe('ChatView', () => { turnEnds: new Map([[1, 6]]), }) const view = render() - const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + const toggle = view.container.querySelector('[data-turn-process]')! expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1') expect(toggle.getAttribute('data-turn-process-messages')).toBe('1') expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1') const members = [...view.container.querySelectorAll('[data-turn-process-member]')] - expect(members).toHaveLength(3) + expect(members).toHaveLength(4) expect(members.map(member => member.getAttribute('hidden'))) - .toEqual(['until-found', 'until-found', 'until-found']) - expect(members[0]?.textContent).toContain('inspect the repository') - expect(members[1]?.textContent).toContain('bash:a') - expect(members[2]?.textContent).toContain('subagent:b') + .toEqual(['until-found', 'until-found', 'until-found', 'until-found']) + expect(members[0]?.textContent).toContain('思考') + expect(members[0]?.textContent).not.toContain('inspect the repository') + expect(members[2]?.textContent).toContain('bash:a') + expect(members[3]?.textContent).toContain('subagent:b') expect(view.getByText('final answer')).toBeTruthy() fireEvent.click(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null, null]) fireEvent.click(toggle) expect(members.map(member => member.getAttribute('hidden'))) - .toEqual(['until-found', 'until-found', 'until-found']) + .toEqual(['until-found', 'until-found', 'until-found', 'until-found']) fireEvent(members[1]!, new Event('beforematch')) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null, null]) act(() => { h.set({ nodes: [user(1, 'question'), first] }) }) - expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-turn-process]')!.getAttribute('aria-expanded')).toBe('false') expect(members[0]?.getAttribute('hidden')).toBeNull() act(() => { h.set({ nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second], }) }) - const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' }) + const renewedToggle = view.container.querySelector('[data-turn-process]')! expect(renewedToggle.getAttribute('aria-expanded')).toBe('true') expect(members[0]?.getAttribute('hidden')).toBeNull() }) - it('folds injected Context in place with the rest of the Turn process', () => { - const h = makeHarness({ - nodes: [ - user(1, 'question'), - context(2, 'runtime policy changed', 1), - reasoningAssistant(3, 'inspect the repository', 1, 1), - toolResult(4, 'a'), - assistant(5, 'final answer', 1, 2), - ], - turnEnds: new Map([[1, 6]]), - }) + it.each(['compact', 'detailed', 'expanded'] as const)('hides infrastructure rows without empty disclosures in %s', (mode) => { + const source = withSystemPrompt(chatSnapshotFixture({ + nodes: [user(1, 'question'), context(2, 'runtime policy', 1), + command({ seq: 2.5, name: 'permission', args: 'read-only' }), + assistant(3, 'final answer', 1, 1)], + turnEnds: new Map([[1, 4]]), + }), new ChatSnapshotBuilder()) + const h = makeHarness({ chat: source }) + act(() => { h.setTranscriptView(mode) }) const view = render() - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') - const members = [...view.container.querySelectorAll('[data-turn-process-member]')] - - expect(members).toHaveLength(3) - expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call']) - expect(contextRow).not.toBeNull() - expect(contextRow?.getAttribute('hidden')).toBe('until-found') - fireEvent(contextRow!, new Event('beforematch')) - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null]) + expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull() + expect(view.container.querySelector('[data-chat-flow-kind="context"]')).toBeNull() + expect(view.container.querySelector('[data-chat-flow-kind="command"]')).toBeNull() + expect(view.container.querySelector('[data-step-process]')).toBeNull() + expect(turnProcessControl(view.container)?.querySelector('svg')).toBeNull() + expect(view.getByText('final answer')).toBeTruthy() }) - it('keeps the first System prompt above User and outside Process through completion and expansion', () => { - const builder = new ChatSnapshotBuilder() - const initial = withSystemPrompt(chatSnapshotFixture({ - nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)], - }), builder) - const h = makeHarness({ chat: initial }, { running: true }) - const view = render() - const promptRow = view.container.querySelector('[data-chat-flow-kind="system-prompt"]')! - - expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context']) - expect(promptRow.getAttribute('hidden')).toBeNull() - expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) - - act(() => { - h.set({ - chat: withSystemPrompt(chatSnapshotFixture({ - nodes: [ - userInTurn(2, 'question', 1), - context(3, 'runtime policy', 1), - reasoningAssistant(4, 'inspect', 1, 1), - ], - }), builder), - running: true, - }) - }) - expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', - ]) - expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow) - expect(promptRow.getAttribute('hidden')).toBeNull() - - act(() => { - h.set({ - chat: withSystemPrompt(chatSnapshotFixture({ - nodes: [ - userInTurn(2, 'question', 1), - context(3, 'runtime policy', 1), - reasoningAssistant(4, 'inspect', 1, 1), - assistant(6, 'final answer', 1, 2), - ], - turnEnds: new Map([[1, 7]]), - }), builder), - running: false, - }) - }) - const toggle = turnProcessControl(view.container)! - const members = [...view.container.querySelectorAll('[data-turn-process-member]')] - expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', - ]) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(promptRow.getAttribute('hidden')).toBeNull() - expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false) - expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step']) - expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found']) - - fireEvent.click(toggle) - expect(renderedFlowKinds(view.container)).toEqual([ - 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail', - ]) - expect(promptRow.getAttribute('hidden')).toBeNull() - expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null]) - }) - - it('folds Context under the fallback title when every summary count is zero', () => { - const h = makeHarness({ - nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)], + it.each(['compact', 'detailed', 'expanded'] as const)('retains a waking context notice above the turn control in %s', (mode) => { + const source = chatSnapshotFixture({ + nodes: [context(1, 'goal continuation', 1), reasoningAssistant(2, 'work', 1, 1), + assistant(3, 'answer', 1, 2)], turnEnds: new Map([[1, 4]]), }) + const chat = new ChatSnapshotBuilder().replace({ + nodes: source.nodes.values().map(node => node.kind === 'context' ? { ...node, kind: 'turn-trigger' } : node), + timeline: source.timeline, + }) + const h = makeHarness({ chat }) + act(() => { h.setTranscriptView(mode) }) const view = render() - const toggle = view.getByRole('button', { name: '已思考' }) - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') - - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0') - expect(toggle.getAttribute('data-turn-process-messages')).toBe('0') - expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0') - expect(contextRow?.getAttribute('hidden')).toBe('until-found') - fireEvent.click(toggle) - expect(contextRow?.getAttribute('hidden')).toBeNull() + const notice = view.container.querySelector('[data-chat-flow-kind="turn-trigger"]')! + expect(notice).not.toBeNull() + expect(notice.closest('[hidden]')).toBeNull() + const kinds = renderedFlowKinds(view.container) + expect(kinds.indexOf('turn-trigger')).toBeLessThan(kinds.indexOf('turn-process')) + fireEvent.click(turnProcessControl(view.container)!) + fireEvent.click(turnProcessControl(view.container)!) + expect(notice.closest('[hidden]')).toBeNull() }) it('keeps ordinary spacing when steering separates the process control from its answer', () => { @@ -1598,6 +1751,44 @@ describe('ChatView', () => { expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false) }) + it.each([true, false])('shows a ticking control and collapses at completion (answer: %s)', (hasAnswer) => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + try { + const h = makeHarness({ nodes: [user(1, 'question')], running: true, + turnTimings: new Map([[1, { startTime: 10_000 }]]) }) + h.props.t = makeTranslate(en, commonEn) + const view = render() + const toggle = view.getByRole('button', { name: 'Deep diving for 1s' }) + expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite') + expect(toggle.querySelector('[data-text-shimmer]')).toBeNull() + expect(toggle.getAttribute('aria-expanded')).toBeNull() + act(() => { vi.advanceTimersByTime(2_000) }) + expect(toggle.textContent).toBe('Deep diving for 2s') + fireEvent.click(toggle) + expect((toggle as HTMLButtonElement).disabled).toBe(true) + expect(toggle.getAttribute('aria-expanded')).toBeNull() + act(() => { h.set({ nodes: [user(1, 'question'), reasoningAssistant(2, 'working context', 1, 0)] }) }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.querySelector('svg')).toBeNull() + const group = view.container.querySelector('[data-step-process]')! + expect(group.getAttribute('hidden')).toBeNull() + act(() => { h.set({ nodes: [user(1, 'question'), reasoningAssistant(2, 'working context', 1, 0), ...(hasAnswer ? [assistant(3, 'answer')] : [])], + running: false, turnEnds: new Map([[1, 4]]), + turnTimings: new Map([[1, { startTime: 10_000, endTime: 12_000 }]]) }) }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(group.getAttribute('hidden')).toBe('until-found') + expect((toggle as HTMLButtonElement).disabled).toBe(false) + expect(toggle.querySelector('svg')).not.toBeNull() + act(() => { vi.advanceTimersByTime(3_000) }) + expect(toggle.textContent).toBe('Took 2s') + view.unmount() + } finally { + cleanup() + vi.useRealTimers() + } + }) + it('keeps a live Turn expanded and folds it once at turn/end', () => { const process = assistant(2, 'inspect', 1, 1) const h = makeHarness({ @@ -1606,8 +1797,8 @@ describe('ChatView', () => { running: true, }) const view = render() - expect(turnProcessControl(view.container)).toBeNull() - const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true') + const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement act(() => { h.set({ @@ -1619,28 +1810,88 @@ describe('ChatView', () => { }) const toggle = turnProcessControl(view.container)! expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(processRow.getAttribute('hidden')).toBe('until-found') + expect(processRow().getAttribute('hidden')).toBe('until-found') }) - it('switches completed Turns between the persisted Normal and Compact modes', () => { + it('switches completed Turns between the persisted Expanded and Compact modes', () => { const process = assistant(2, 'inspect', 1, 1) const h = makeHarness({ nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)], turnEnds: new Map([[1, 5]]), }) const view = render() - const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement + const processRow = () => view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') - expect(processRow.getAttribute('hidden')).toBe('until-found') + expect(processRow().getAttribute('hidden')).toBe('until-found') - act(() => { h.setTranscriptView('normal') }) - expect(turnProcessControl(view.container)).toBeNull() - expect(processRow.getAttribute('hidden')).toBeNull() + act(() => { h.setTranscriptView('expanded') }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + expect(processRow().getAttribute('hidden')).toBe('until-found') + fireEvent.click(turnProcessControl(view.container)!) + expect(processRow().getAttribute('hidden')).toBeNull() + fireEvent.click(turnProcessControl(view.container)!) act(() => { h.setTranscriptView('compact') }) expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') - expect(processRow.getAttribute('hidden')).toBe('until-found') + expect(processRow().getAttribute('hidden')).toBe('until-found') + }) + + it('resets nested process disclosures when the whole Turn closes', () => { + const h = makeHarness({ nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')], + turnEnds: new Map([[1, 4]]) }) + const view = render() + const outer = turnProcessControl(view.container)! + fireEvent.click(outer) + const inner = view.container.querySelector('[data-step-process] > button')! + fireEvent.click(inner) + expect(inner.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(outer) + fireEvent.click(outer) + expect(inner.getAttribute('aria-expanded')).toBe('false') + }) + + it('previews settled reasoning only in Detailed and Expanded work details', () => { + const final = { + ...assistant(3, 'answer', 1, 1), + blocks: [ + { kind: 'reasoning' as const, text: '**Inspect the session**\nCheck persistence' }, + { kind: 'text' as const, text: 'answer' }, + ], + } + const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) }) + const view = render() + expect(view.queryByText('Inspect the session')).toBeNull() + for (const mode of ['detailed', 'expanded'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(view.getByText('Inspect the session')).toBeTruthy() + expect(view.queryByText('Check persistence')).toBeNull() + } + act(() => { h.setTranscriptView('compact') }) + expect(view.queryByText('Inspect the session')).toBeNull() + }) + + it('removes secondary groups in Expanded and restores collapsed groups in Detailed', () => { + const h = makeHarness({ nodes: [user(1, 'question'), reasoningAssistant(2, 'work', 1, 0), assistant(3, 'answer')], + turnEnds: new Map([[1, 4]]) }) + const view = render() + act(() => { h.setTranscriptView('detailed') }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-step-process-body]')?.getAttribute('hidden')).toBe('until-found') + act(() => { h.setTranscriptView('expanded') }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(turnProcessControl(view.container)!) + expect(view.container.querySelector('[data-step-process]')).toBeNull() + expect(view.container.querySelector('[data-step-process-body]')).toBeNull() + const contextRow = view.container.querySelector('[data-chat-flow-kind="assistant-step"]')! + expect(contextRow.parentElement?.hasAttribute('data-chat-flow')).toBe(true) + expect(contextRow.closest('[hidden]')).toBeNull() + expect(view.getByText('answer')).toBeTruthy() + act(() => { h.setTranscriptView('detailed') }) + const body = view.container.querySelector('[data-step-process-body]')! + expect(body.getAttribute('hidden')).toBe('until-found') + fireEvent.click(view.container.querySelector('[data-step-process] > button')!) + expect(body.hasAttribute('hidden')).toBe(false) }) it('folds final-step reasoning under the fallback title when every summary count is zero', () => { @@ -1653,12 +1904,14 @@ describe('ChatView', () => { } const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) }) const view = render() - const toggle = view.getByRole('button', { name: '已思考' }) + const toggle = view.getByRole('button', { name: '已完成工作' }) const reasoning = view.container.querySelector('[data-turn-process-inline]') expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(reasoning?.getAttribute('hidden')).toBe('until-found') expect(view.getByText('final answer')).toBeTruthy() fireEvent.click(toggle) + expect(view.queryByText('private analysis')).toBeNull() + fireEvent.click(view.getByText('思考')) expect(view.getByText('private analysis')).toBeTruthy() }) @@ -1684,20 +1937,20 @@ describe('ChatView', () => { it('folds when the process controller first appears off-tail', () => { const h = makeHarness({ - nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + nodes: [user(1, 'question'), reasoningAssistant(2, 'runtime policy', 1, 0)], running: true, }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + const contextRow = view.container.querySelector('[data-chat-flow-kind="assistant-step"]') readerScroll(scroller, 100) act(() => { h.set({ nodes: [ user(1, 'question'), - context(2, 'runtime policy', 1), + reasoningAssistant(2, 'runtime policy', 1, 0), assistant(3, 'final answer', 1, 1), ], running: false, @@ -1711,19 +1964,20 @@ describe('ChatView', () => { it('keeps a focused process row visible when a live Turn completes', () => { const h = makeHarness({ - nodes: [user(1, 'question'), context(2, 'runtime policy', 1)], + nodes: [user(1, 'question'), reasoningAssistant(2, 'runtime policy', 1, 0)], running: true, }) const view = render() - const contextToggle = view.getByRole('button', { name: '上下文注入' }) - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') + fireEvent.click(view.getByRole('button', { name: '正在分析请求' })) + const contextToggle = view.getByRole('button', { name: '思考' }) + const contextRow = view.container.querySelector('[data-chat-flow-kind="assistant-step"]') contextToggle.focus() expect(document.activeElement).toBe(contextToggle) act(() => { h.set({ nodes: [ user(1, 'question'), - context(2, 'runtime policy', 1), + reasoningAssistant(2, 'runtime policy', 1, 0), assistant(3, 'final answer', 1, 1), ], running: false, @@ -1740,28 +1994,42 @@ describe('ChatView', () => { expect(contextRow?.getAttribute('hidden')).toBe('until-found') }) - it('keeps a foldable closed Turn fully visible while history is partial', () => { + it.each(['ordinary', 'goal'])('folds a complete %s Turn while older history remains unloaded', (kind) => { const h = makeHarness({ nodes: [ - user(1, 'question'), - context(2, 'runtime policy', 1), + ...(kind === 'ordinary' ? [user(1, 'question')] : []), + reasoningAssistant(2, 'goal continuation', 1, 0), assistant(3, 'working', 1, 1), assistant(4, 'final answer', 1, 2), ], + turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]), turnEnds: new Map([[1, 5]]), hasMore: true, }) const view = render() - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') - - expect(turnProcessControl(view.container)).toBeNull() - expect(contextRow?.getAttribute('hidden')).toBeNull() - expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false) - - act(() => { h.set({ hasMore: false }) }) const toggle = turnProcessControl(view.container)! expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(contextRow?.getAttribute('hidden')).toBe('until-found') + const group = view.container.querySelector('[data-step-process]')! + expect(group.getAttribute('hidden')).toBe('until-found') + fireEvent.click(toggle) + expect(group.getAttribute('hidden')).toBeNull() + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('folds a truncated Turn when its start arrives even with more history unloaded', () => { + const h = makeHarness({ + nodes: [reasoningAssistant(2, 'goal continuation', 1, 0), assistant(3, 'working', 1, 1), + assistant(4, 'final answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + hasMore: true, + }) + const view = render() + expect(turnProcessControl(view.container)).toBeNull() + const group = view.container.querySelector('[data-step-process]')! + expect(group.getAttribute('hidden')).toBeNull() + act(() => { h.set({ turnTimings: new Map([[1, { startTime: 0, endTime: 4_000 }]]) }) }) + expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') + expect(group.getAttribute('hidden')).toBe('until-found') }) it('withholds process controls for partial history and folds final-page groups', () => { @@ -1965,9 +2233,10 @@ describe('ChatView', () => { expect(dialog.textContent).toContain('缓存命中49.4%') expect(dialog.textContent).toContain('未缓存输入5,060 tok') fireEvent.keyDown(document, { key: 'Escape' }) - expect(view.queryByRole('button', { name: /用时/ })).toBeNull() + expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒') act(() => { h.setPerformanceUsage('compact') }) expect(view.queryByRole('button', { name: /用量/ })).toBeNull() + expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒') act(() => { h.setPerformanceUsage('detailed') }) expect(view.getByRole('button', { name: /用量/ })).toBeTruthy() }) @@ -1984,7 +2253,7 @@ describe('ChatView', () => { turnEnds: new Map([[1, 20]]), }) const view = render() - expect(view.queryByRole('button', { name: /用时/ })).toBeNull() + expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒') expect(view.queryByRole('button', { name: /用量/ })).toBeNull() }) @@ -2048,26 +2317,14 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('disables fork when the indexed Turn has a later steering Node', () => { - const base = chatSnapshotFixture({ - nodes: [user(1, 'question'), assistant(2, 'answer')], + it('keeps actions at the bottom and hover-only after later steering', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer'), steering(3, 'change direction', 1)], turnEnds: new Map([[1, 4]]), }) - const chat = { - ...base, - locations: { - getTurn: (turn: number) => turn === 1 - ? [...base.locations.getTurn(turn), 'fixture:steering:later'] - : base.locations.getTurn(turn), - getStep: (turn: number, step: number) => base.locations.getStep(turn, step), - }, - } - const h = makeHarness({}, {}, chat) const view = render() - const branch = view.getByRole('button', { name: '在新对话中分支' }) - expect(branch.getAttribute('aria-disabled')).toBe('true') - fireEvent.click(branch) - expect(h.forkAt).not.toHaveBeenCalled() + expect(view.container.querySelector('[data-turn-tail="1"]')?.getAttribute('data-actions-reveal')).toBe('hover') + expect(view.getByRole('button', { name: '在新对话中分支' }).getAttribute('aria-disabled')).toBe('true') }) it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { @@ -2084,6 +2341,11 @@ describe('ChatView', () => { const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(buttons).toHaveLength(1) expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true') + const tail = view.container.querySelector('[data-turn-tail="1"]')! + expect(tail.getAttribute('data-actions-reveal')).toBe('hover') + expect(view.container.querySelectorAll('[data-chat-flow-kind]').item( + view.container.querySelectorAll('[data-chat-flow-kind]').length - 1, + ).getAttribute('data-chat-flow-kind')).toBe('turn-tail') fireEvent.click(buttons[0]!) expect(h.forkAt).not.toHaveBeenCalled() }) @@ -2171,10 +2433,11 @@ describe('ChatView', () => { it('hands running calls to a live Tool group', () => { const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true }) + h.setTranscriptView('detailed') const view = render() expect(view.getByTestId('tool-seat-r1')).toBeTruthy() expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' }) - expect(view.getByRole('status').textContent).toBe('深度求索中...') + expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite') }) it('keeps the Tool renderer mounted when a running call settles into log order', () => { @@ -2194,7 +2457,7 @@ describe('ChatView', () => { } const h = makeHarness({ - nodes: [user(1, 'q'), assistant(4, 'later')], + nodes: [userInTurn(1, 'q', 2), assistant(2, 'starting', 2), assistant(4, 'later', 2)], runningCalls: [runningCall('r1')], }, { running: true }) h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { @@ -2211,7 +2474,7 @@ describe('ChatView', () => { act(() => { h.setChat({ - nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], + nodes: [userInTurn(1, 'q', 2), assistant(2, 'starting', 2), toolResult(3, 'r1'), assistant(4, 'later', 2)], runningCalls: [], }) h.setSession({ running: false }) @@ -2224,18 +2487,18 @@ describe('ChatView', () => { expect(unmounted).not.toHaveBeenCalled() }) - it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { + it('Detailed uses the whole-turn clock and omits a standalone running status', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } const h = makeHarness( { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) }, { running: true }, ) + h.setTranscriptView('detailed') const view = render() - // Freshly mounted (as after a reload) yet already past the 15s gate. - const status = view.getByRole('status') - expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) - expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull() + const control = turnProcessControl(view.container)! + expect(control.textContent).toMatch(/^深度求索中,用时2分\d{1,2}秒$/) + expect(view.getByRole('status').getAttribute('aria-live')).toBe('polite') act(() => { h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{ id: 'steering-occurrence' as never, @@ -2244,18 +2507,20 @@ describe('ChatView', () => { role: 'user', source: { kind: 'user' }, }] } }) }) - expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/) + expect(control.textContent).toMatch(/^深度求索中,用时2分\d{1,2}秒$/) }) - it('the running clock reads hours once the turn passes an hour', () => { + it('the whole-turn running clock reads hours once the turn passes an hour', () => { const startTime = Date.now() - 3_903_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } const h = makeHarness( { nodes: [trigger], turnTimings: new Map([[1, { startTime }]]) }, { running: true }, ) + h.setTranscriptView('detailed') const view = render() - expect(view.getByRole('status').textContent).toMatch(/^深度求索中\.\.\.1小时05分0\d秒$/) + expect(turnProcessControl(view.container)?.textContent) + .toMatch(/^深度求索中,用时1小时05分\d{1,2}秒$/) }) it('hands each ordered root call to the keyed business-node slot', () => { @@ -2909,7 +3174,7 @@ describe('ChatView', () => { expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => { + it('renders command nodes as durable rows: settled text, error state, executing shimmer, run-less soft-fall', () => { // Settled success: the bare command name is the title, the outcome text // the summary — neither the dispatched `/` nor its arguments reach the row // (the settlement text already says what the command did). @@ -2936,6 +3201,7 @@ describe('ChatView', () => { }) const xv = render() expect(xv.container.querySelector('[data-state="running"]')).not.toBeNull() + expect(xv.container.querySelectorAll('[data-text-shimmer]')).toHaveLength(2) expect(xv.getByText('执行中…')).toBeTruthy() expect(xv.getByText('运行中')).toBeTruthy() diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f393a952bf..4aa2160c27 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -21,7 +21,7 @@ import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapsh import { commandDefinition } from '../src/client/conversation-nodes/command.ts' import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' -import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' +import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../src/client/conversation-nodes/message.ts' import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts' @@ -37,6 +37,7 @@ import type { const DEFINITIONS: readonly ConversationNodeDefinition[] = [ nextStepInboxDefinition, + nextTurnInboxDefinition, messageDefinition, systemMessageDefinition(inspectSystemPrompt), requestPromptDefinition(inspectRequestPrompt), @@ -220,6 +221,13 @@ function toolResult(callId: string, text: string, isError = false) { } describe('built-in conversation node Definitions', () => { + it('publishes the process control as soon as turn/start arrives', () => { + const value = assembler([at(1, 'turn/start', { turn: 1 })]) + expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ + turn: 1, controlAnchorSeq: 1, answerAnchorSeq: null, + }) + }) + it('rejects an unrelated event passed directly to the request-prompt start', () => { const input = at(1, 'turn/start', { turn: 1 }) const invalidStart = { @@ -463,7 +471,7 @@ describe('built-in conversation node Definitions', () => { ]) const opening = snapshot(value) expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([ - 'user', 'context', + 'user', 'turn-process', 'context', ]) value.append(at(5, 'assistant/live-chunk', { @@ -878,7 +886,7 @@ describe('built-in conversation node Definitions', () => { }, { surfaceOp: 'append' }), ]) const toolOnlySnapshot = snapshot(toolOnlyValue) - expect(toolOnlySnapshot.order).toEqual([]) + expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process']) expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ kind: 'assistant', @@ -1353,7 +1361,7 @@ describe('built-in conversation node Definitions', () => { expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ - 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user', + 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user', 'turn-process', ]) }) @@ -1447,7 +1455,7 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'user')).toBeUndefined() }) - it('orders claimed steering after the finalized Turn tail', () => { + it('keeps the Turn tail below steering received after a response', () => { const steering = textMessage('steer-after-answer', 'change direction') const value = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -1476,7 +1484,7 @@ describe('built-in conversation node Definitions', () => { const current = snapshot(value) const steeringNode = node(current, 'steering') expect(steeringNode).toBeDefined() - expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key) + expect(current.locations.getTurn(1).at(-1)).toBe(node(current, 'turn-tail')?.key) }) it('classifies appended producer context from durable source metadata', () => { @@ -1624,6 +1632,7 @@ describe('built-in conversation node Definitions', () => { expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([ 'system-prompt', 'user', + 'turn-process', 'context', ]) expect(node(current, 'system-prompt')?.anchorSeq).toBe(1) @@ -1700,12 +1709,12 @@ describe('built-in conversation node Definitions', () => { ]) const current = snapshot(value) - expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user', 'turn-process']) value.append(systemAt(5, '# Replaced', 3)) value.flush() const replaced = snapshot(value) - expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user', 'turn-process']) }) it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => { @@ -1780,7 +1789,7 @@ describe('built-in conversation node Definitions', () => { ]) const current = snapshot(value) - expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user']) + expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user', 'turn-process']) }) it('keeps the initial system prompt before the opening User as Turn process state changes', () => { @@ -1804,7 +1813,7 @@ describe('built-in conversation node Definitions', () => { } const promptKey = node(snapshot(value), 'system-prompt')?.key - expect(kinds()).toEqual(['system-prompt', 'user', 'context']) + expect(kinds()).toEqual(['system-prompt', 'user', 'turn-process', 'context']) value.append(at(7, 'assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, @@ -2518,3 +2527,50 @@ describe('built-in conversation node Definitions', () => { }) }) }) + + +describe('non-human turn triggers', () => { + it.each(['next-turn', 'next-step'] as const)('classifies non-human opening claims (%s)', (target) => { + const message = { ...textMessage('wake', 'scheduled task'), source: { kind: 'plugin', plugin: 'schedule' } } + const value = assembler([ + at(1, 'agent/inbox/spliced', { target, start: 0, inserted: [message] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target, start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', message, { surfaceOp: 'append' }), + at(6, 'user/message', { ...textMessage('policy', 'AGENTS.md'), source: { kind: 'plugin', plugin: 'instructions' } }, + { surfaceOp: 'append' }), + ]) + const current = snapshot(value) + const kinds = current.order.map(key => current.nodes.get(key)?.kind) + expect(kinds).toEqual(['turn-trigger', 'turn-process', 'context']) + }) + + it.each([1, 2])('keeps next-step context inside a human turn (step %s)', (step) => { + const human = textMessage('human', 'work') + const notice = { ...textMessage('notice', 'background result'), source: { kind: 'plugin', plugin: 'tool-jobs' } } + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }), + at(2, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [notice] }), + at(3, 'turn/start', { turn: 1 }), + at(4, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(5, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(6, 'step/start', { turn: 1, step }), + at(7, 'user/message', notice, { surfaceOp: 'append' }), + at(8, 'user/message', human, { surfaceOp: 'append' }), + ]) + expect([...snapshot(value).nodes.values()].map(value => value.kind)).not.toContain('turn-trigger') + }) + + it('does not reinterpret user-attributed SDK input as an external trigger', () => { + const message = textMessage('sdk', 'work') + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', message, { surfaceOp: 'append' }), + ]) + expect(snapshot(value).order.map(key => snapshot(value).nodes.get(key)?.kind)).toEqual(['user', 'turn-process']) + }) +}) diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index 2a2883ebb3..b2b937630a 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -27,7 +27,7 @@ describe('tails', () => { />, ) expect(view.getByText('思考')).toBeTruthy() - expect(view.getByText('thinking hard')).toBeTruthy() + expect(view.queryByText('thinking hard')).toBeNull() expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( { expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') }) - it('follows the latest streaming line, then restores the settled first line', () => { + it('holds the first line of the current streaming paragraph, then hides settled reasoning in Compact mode', () => { const view = render( { ) expect(view.getByText('运行中')).toBeTruthy() expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end')) + expect(view.getByText('Inspect the session').parentElement?.getAttribute('data-streaming')) .toBe('true') view.rerender( @@ -59,24 +59,63 @@ describe('ReasoningRow', () => { renderMessageImages={renderMessageImages} />, ) - expect(view.getByText('Newest reasoning tokens keep arriving').parentElement - ?.getAttribute('data-follow-end')).toBe('true') + expect(view.getByText('Inspect the session')).toBeTruthy() + expect(view.queryByText('Newest reasoning tokens keep arriving')).toBeNull() view.rerender( , + ) + expect(view.getByText('Inspect the session')).toBeTruthy() + expect(view.queryByText('Compare the persisted events')).toBeNull() + + view.rerender( + , + ) + expect(view.getByText('Compare the persisted events')).toBeTruthy() + expect(view.queryByText('without replacing the summary')).toBeNull() + + view.rerender( + , ) - const settledSummary = view.getByText('Inspect the session') - expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(view.getByRole('button').textContent).toBe('思考') + expect(view.queryByText('Inspect the session')).toBeNull() expect(view.queryByText('运行中')).toBeNull() - expect(settledSummary.parentElement?.hasAttribute('data-follow-end')).toBe(false) + }) - it('expands from either Think or the reasoning summary', () => { + it.each(['\n', '\r\n'])('holds completed paragraph previews across streamed %j line endings', (newline) => { + const renderText = (text: string) => ( + + ) + const view = render(renderText(`First paragraph${newline}${newline}Pending`)) + expect(view.getByText('First paragraph')).toBeTruthy() + expect(view.queryByText('Pending')).toBeNull() + + view.rerender(renderText(`First paragraph${newline}${newline}Second paragraph${newline} ${newline}Third`)) + expect(view.getByText('Second paragraph')).toBeTruthy() + expect(view.queryByText('Third')).toBeNull() + + view.rerender(renderText(`First paragraph${newline}${newline}Second paragraph${newline} ${newline}Third${newline}More tokens`)) + expect(view.getByText('Third')).toBeTruthy() + expect(view.queryByText('More tokens')).toBeNull() + }) + + it('expands completed reasoning from the Think title', () => { const view = render( { ) const row = view.getByRole('button') - fireEvent.click(view.getByText('Inspect the session')) + fireEvent.click(view.getByText('思考')) expect(row.getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -103,7 +142,7 @@ describe('ReasoningRow', () => { }, { label: 'streaming', - text: 'Inspect the session\n**Comparing checkout and merge bases**', + text: 'Inspect the session\n\n**Comparing checkout and merge bases**\nKeep **reviewing**', streaming: true, }, ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { @@ -116,7 +155,8 @@ describe('ReasoningRow', () => { />, ) - expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() + if (streaming) expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() + else expect(view.getByRole('button').textContent).toBe('思考') expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() fireEvent.click(view.getByText('思考')) @@ -124,7 +164,7 @@ describe('ReasoningRow', () => { expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).not.toContain('**') }) - it('keeps heading syntax in the collapsed summary and renders compact headings when expanded', () => { + it('renders compact headings only while expanded', () => { const text = Array.from({ length: 6 }, (_, index) => `${'#'.repeat(index + 1)} Section ${index + 1}`) .join('\n\n') + '\n\nReasoning body.' const view = render( @@ -135,18 +175,17 @@ describe('ReasoningRow', () => { renderMessageImages={renderMessageImages} />, ) - const summary = view.getByText('# Section 1') - expect(summary.tagName).toBe('SPAN') + expect(view.getByRole('button').textContent).toBe('思考') expect(view.queryByRole('heading')).toBeNull() - fireEvent.click(summary) + fireEvent.click(view.getByText('思考')) const compact = view.container.querySelector('[data-markdown-variant="compact"]') expect(compact).not.toBeNull() expect(compact?.querySelectorAll('h1, h2, h3, h4, h5, h6')).toHaveLength(6) expect(compact?.querySelector('p')?.textContent).toBe('Reasoning body.') fireEvent.click(view.getByText('思考')) - expect(view.getByText('# Section 1').tagName).toBe('SPAN') + expect(view.queryByText('# Section 1')).toBeNull() expect(view.queryByRole('heading')).toBeNull() }) diff --git a/packages/client/ui-chat/tests/step-process-index.client.spec.ts b/packages/client/ui-chat/tests/step-process-index.client.spec.ts new file mode 100644 index 0000000000..52ed18b42d --- /dev/null +++ b/packages/client/ui-chat/tests/step-process-index.client.spec.ts @@ -0,0 +1,89 @@ +/** Process projection keeps streaming work local to the affected group. */ +import { describe, expect, it, vi } from 'vitest' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { ChatStepProcessProjector, processLayoutChanged } from '../src/client/conversation-nodes/step-process-index.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' + +function history(turns: number) { + return chatSnapshotFixture({ + nodes: Array.from({ length: turns }, (_, i) => ({ + kind: 'assistant' as const, seq: i + 1, time: i, turn: i + 1, step: 1, + blocks: [{ kind: 'reasoning' as const, text: 'Inspect history' }, { kind: 'text' as const, text: 'Done' }], + })), + partial: { turn: turns + 1, step: 1, blocks: [{ kind: 'reasoning', text: 'Inspect current work' }] }, + }) +} + +describe('step process projection', () => { + it.each([10, 1000])('reads only the changed group with %i historical turns', (turns) => { + const snapshot = history(turns) + const index = new ChatStepProcessProjector() + index.replace(snapshot) + const layout = index.layout + const oldGroups = layout.filter(range => range.process).map(range => index.get(range.key)!) + const live = oldGroups.at(-1)! + const node = live.nodes[0]! + expect(node.kind).toBe('assistant-step') + if (node.kind !== 'assistant-step') throw new Error('missing live assistant') + const changed: ChatNode<'assistant-step'> = { + ...node, data: { ...node.data, blocks: [{ kind: 'reasoning', text: 'Inspect current work in more detail' }] }, + } + expect(processLayoutChanged(node, changed)).toBe(false) + const get = vi.fn((key: string) => key === changed.key ? changed : snapshot.nodes.get(key)) + index.update({ ...snapshot, nodes: { ...snapshot.nodes, get } }, [changed.key]) + expect(get.mock.calls.map(([key]) => key)).toEqual([changed.key]) + expect(index.layout).toBe(layout) + for (const group of oldGroups.slice(0, -1)) expect(index.get(group.range.key)).toBe(group) + expect(index.get(live.range.key)?.summary.runningDetail).toBe('Inspect current work in more detail') + expect(index.get(live.range.key)).not.toBe(live) + }) + + it('regroups when a response begins and preserves preceding groups', () => { + const fixture = history(2) + const builder = new ChatSnapshotBuilder() + const before = builder.replace({ nodes: fixture.nodes.values(), timeline: fixture.timeline }) + const historical = before.stepProcesses.layout.filter(range => range.process).map(range => before.stepProcesses.get(range.key)!) + const live = historical.at(-1)!.nodes[0]! + if (live.kind !== 'assistant-step') throw new Error('missing live assistant') + const response: ChatNode<'assistant-step'> = { + ...live, data: { ...live.data, blocks: [...live.data.blocks, { kind: 'text', text: 'Answer' }] }, + } + expect(processLayoutChanged(live, response)).toBe(true) + const after = builder.apply({ upserts: [response], timeline: fixture.timeline }) + for (const group of historical.slice(0, -1)) expect(after.stepProcesses.get(group.range.key)).toBe(group) + expect(after.stepProcesses.get(historical.at(-1)!.range.key)?.range.closed).toBe(true) + expect(after.stepProcesses.layout.at(-1)?.seats[0]?.assistantPart).toBe('response') + const layout = after.stepProcesses.layout + const updated = builder.apply({ upserts: [{ ...response, data: { + ...response.data, blocks: [...live.data.blocks, { kind: 'text', text: 'Answer continued' }], + } }], timeline: fixture.timeline }) + expect(updated.stepProcesses.layout).toBe(layout) + }) + + it('keeps unknown trailing turns live until their end is known', () => { + const snapshot = history(0) + const turn = snapshot.timeline.turns.get(1)! + const index = new ChatStepProcessProjector() + index.replace({ ...snapshot, timeline: { + ...snapshot.timeline, turns: new Map([[1, { ...turn, status: 'unknown' }]]), + } }) + const key = index.layout.find(range => range.process)!.key + expect(index.get(key)?.range.closed).toBe(false) + index.replace({ ...snapshot, timeline: { + ...snapshot.timeline, turns: new Map([[1, { ...turn, status: 'closed' }]]), + } }) + expect(index.get(key)?.range.closed).toBe(true) + }) + + it('drops groups and footer facts when the loaded window is replaced', () => { + const populated = history(2) + const index = new ChatStepProcessProjector() + index.replace(populated) + const key = index.layout.find(range => range.process)!.key + index.replace(chatSnapshotFixture({ nodes: [] })) + expect(index.layout).toEqual([]) + expect(index.get(key)).toBeUndefined() + expect(index.footer(1)).toBeUndefined() + }) +}) diff --git a/packages/client/ui-chat/tests/step-process-spacing.client.spec.ts b/packages/client/ui-chat/tests/step-process-spacing.client.spec.ts new file mode 100644 index 0000000000..059bc6d990 --- /dev/null +++ b/packages/client/ui-chat/tests/step-process-spacing.client.spec.ts @@ -0,0 +1,18 @@ +/** CSS checks for the secondary process title's surrounding vertical rhythm. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +describe('secondary process spacing', () => { + it('keeps 16px around the title and 8px between expanded rows', () => { + const flow = read('ChatView.module.css') + expect(flow.match(/--dsh-chat-flow-gap:\s*16px/g)).toHaveLength(2) + + const process = read('StepProcessList.module.css') + expect(process).toMatch(/\.title\[aria-expanded="true"\]\s*\{[^}]*padding-bottom:\s*16px/s) + expect(process).toMatch(/\.body\s*\{[^}]*--dsh-chat-flow-gap:\s*8px/s) + }) +}) diff --git a/packages/client/ui-chat/tests/step-process.client.spec.ts b/packages/client/ui-chat/tests/step-process.client.spec.ts new file mode 100644 index 0000000000..aa340ad1e8 --- /dev/null +++ b/packages/client/ui-chat/tests/step-process.client.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { processTitle } from '../src/client/chat/step-process.ts' +import { processActivity, processRanges } from '../src/client/conversation-nodes/step-process.ts' +import { en, zh } from '../src/client/locale.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' +import type { ModelRetryNode, RunningToolCall } from '../src/client/contract/snapshot.ts' + +function call(callId: string, name: string, file = callId): RunningToolCall { + return { callId, name, argsRaw: JSON.stringify({ file_path: file }), turn: 1, step: 1, time: 1, subCalls: [] } +} + +function nodes(calls: readonly RunningToolCall[]): ChatNode[] { + return calls.flatMap(value => chatSnapshotFixture({ runningCalls: [value] }).nodes.values() as ChatNode[]) +} + +function titles(names: string[], closed = true): string[] { + const summary = processActivity(nodes(names.map((name, i) => call(String(i), name)))) + return [zh, en].map(dictionary => processTitle(summary, closed, makeTranslate(dictionary))) +} + +function retry(retryState: ModelRetryNode['retryState']): ModelRetryNode { + return { kind: 'model-retry', seq: 2, time: 2, retryId: 'retry' as ModelRetryNode['retryId'], turn: 1, step: 1, + provider: 'mock', mode: 'always', policyKey: 'demo', retry: 1, delayMs: 1000, + failure: { message: 'rate limit', code: 'RATE_LIMIT' }, retryState } +} + +describe('process activity summary', () => { + it('counts repeated paths as calls, deduplicates recursive ids and preserves ties in first-seen order', () => { + const read = call('r1', 'read', '/a') + const calls = [read, call('r2', 'read', '/a'), call('r3', 'read', '/b'), + call('e1', 'edit'), call('e2', 'write'), call('e3', 'edit'), + call('b1', 'bash'), call('b2', 'pwsh'), call('b3', 'bash'), call('b4', 'bash'), + call('s', 'grep'), { ...call('code', 'run_code'), subCalls: [read] }] + expect(processActivity(nodes(calls)).counts).toEqual([ + { kind: 'commands', count: 4 }, { kind: 'read', count: 3 }, { kind: 'edit', count: 3 }, + { kind: 'search', count: 1 }, { kind: 'code', count: 1 }, + ]) + }) + + it('reports the newest running nested call even when an older root is visited later', () => { + const root = { ...call('code', 'run_code'), subCalls: [{ ...call('read', 'read'), time: 3, argsRaw: '{' }] } + const summary = processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }])) + expect(summary.running).toBe('read') + expect(summary.runningDetail).toBe('read') + }) + + it('prefers concise task fields, reads question arrays, caps long details, and falls back to the tool name', () => { + const detail = (name: string, args: unknown): string => processActivity(nodes([ + { ...call('current', name), argsRaw: typeof args === 'string' ? args : JSON.stringify(args) }, + ])).runningDetail + expect(detail('exec_command', { command: 'git status', description: 'Inspect current changes' })) + .toBe('Inspect current changes') + expect(detail('request_user_input', { questions: [{ question: 'Which layout should the card use?' }] })) + .toBe('Which layout should the card use?') + expect(detail('spawn_agent', { task_name: 'audit_ui', message: 'Inspect the complete UI implementation.' })) + .toBe('audit_ui') + expect(detail('custom_tool', {})).toBe('custom_tool') + expect(detail('freeform_tool', 'not-json')).toBe('freeform_tool') + expect(detail('custom_tool', { title: 'x'.repeat(200) })).toBe(`${'x'.repeat(159)}…`) + }) + + it('uses the latest live reasoning paragraph while no tool is running', () => { + const snapshot = chatSnapshotFixture({ partial: { turn: 1, step: 1, blocks: [ + { kind: 'reasoning', text: '**Inspect the request**\ncarefully\n\nCompare the current tool output' }, + ] } }) + const summary = processActivity([...snapshot.nodes.values()] as ChatNode[]) + expect(summary.running).toBeUndefined() + expect(summary.runningDetail).toBe('Compare the current tool output') + }) + + it.each([ + [['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'], '正在读取文件', 'Reading files'], + [['grep', 'glob', 'session_inspect'], '正在搜索代码', 'Searching code'], + [['write', 'edit', 'apply_patch'], '正在编辑文件', 'Editing files'], + [['bash', 'pwsh', 'exec_command', 'write_stdin', 'terminal_read'], '正在运行命令', 'Running commands'], + [['run_code'], '正在运行代码', 'Running code'], + [['web_search'], '正在搜索网页', 'Searching the web'], + [['web_fetch'], '正在访问网页', 'Visiting web pages'], + [['subagent', 'subagent_custom'], '正在协调子任务', 'Coordinating subagents'], + [['todo_write', 'create_goal', 'update_goal', 'get_goal'], '正在更新计划', 'Updating the plan'], + [['ask_user_question', 'request_user_input'], '等待你的操作', 'Waiting for your action'], + [['job_output', 'job_list', 'job_kill', 'send_message', 'list_agents', 'custom'], '正在调用工具', 'Calling tools'], + ])('classifies %j with abstract live copy', (names, chinese, english) => { + for (const name of names) expect(titles([name], false)).toEqual([chinese, english]) + }) + + it.each([ + [['read'], '已读取文件', 'Read files'], + [['web_search', 'todo_write'], '已搜索网页并更新了计划', 'Searched the web and updated the plan'], + [['edit', 'bash'], '修改了文件并执行了命令', 'Edited files and ran commands'], + [['read', 'grep', 'edit'], '已读取文件,已搜索代码,修改了文件', 'Read files, searched code, edited files'], + [['read', 'grep', 'edit', 'bash'], '已读取文件,已搜索代码,修改了文件等', 'Read files, searched code, edited files, etc.'], + [['read', 'grep'], '已读取文件并搜索代码', 'Read files and searched code'], + [['read', 'edit'], '已读取文件并修改了文件', 'Read files and edited files'], + [['edit', 'grep'], '修改了文件并已搜索代码', 'Edited files and searched code'], + [['read', 'grep', 'bash'], '已读取文件,已搜索代码,执行了命令', 'Read files, searched code, ran commands'], + [['read', 'grep', 'bash', 'edit'], '已读取文件,已搜索代码,执行了命令等', 'Read files, searched code, ran commands, etc.'], + [['read', 'grep', 'bash', 'edit', 'edit'], '修改了文件,已读取文件,已搜索代码等', 'Edited files, read files, searched code, etc.'], + [['run_code', 'web_search'], '运行了代码并已搜索网页', 'Ran code and searched the web'], + [['web_fetch', 'subagent'], '已访问网页并协调子任务', 'Visited web pages and coordinated subagents'], + [['todo_write', 'ask_user_question', 'job_list'], '更新了计划,向用户提出了问题,已调用工具', 'Updated the plan, asked questions, called tools'], + ])('formats completed categories %j without counts', (names, chinese, english) => { + expect(titles(names)).toEqual([chinese, english]) + }) + + it('keeps retry rows independent of secondary process groups in every phase', () => { + expect(titles([], false)).toEqual(['正在分析请求', 'Analyzing the request']) + expect(titles([])).toEqual(['已完成分析', 'Analysis completed']) + for (const state of ['scheduled', 'started', 'cancelled'] as const) { + const snapshot = chatSnapshotFixture({ nodes: [retry(state)] }) + const ranges = processRanges(snapshot) + const retryRange = ranges.find(range => range.seats.some(seat => snapshot.nodes.get(seat.nodeKey)?.kind === 'model-retry'))! + expect(retryRange.process).toBe(false) + expect(retryRange.seats).toHaveLength(1) + } + }) + + it('closes process work when reply text starts while the turn remains open', () => { + const before = chatSnapshotFixture({ nodes: [retry('started')], partial: { + turn: 1, step: 1, blocks: [{ kind: 'reasoning', text: 'analysis' }], + } }) + expect(processRanges(before).filter(range => range.process).at(-1)?.closed).toBe(false) + const after = chatSnapshotFixture({ nodes: [retry('started')], partial: { + turn: 1, step: 1, blocks: [{ kind: 'reasoning', text: 'analysis' }, { kind: 'text', text: 'Reply begins' }], + } }) + expect(after.timeline.turns.get(1)?.status).toBe('open') + expect(processRanges(after).filter(range => range.process).every(range => range.closed)).toBe(true) + }) +}) diff --git a/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx b/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx deleted file mode 100644 index 1f8c07a3d6..0000000000 --- a/packages/client/ui-chat/tests/system-prompt-row.client.spec.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { ChatNode } from '../src/client/contract/chat-nodes.ts' -import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' -import { en } from '../src/client/locale.ts' - -afterEach(cleanup) - -describe('SystemPromptNodeView', () => { - it('mounts the opaque context body only while its row is expanded', () => { - const text = '# Agent rules\n\n- Read first\n- **Act carefully**' - const node: ChatNode<'system-prompt'> = { - key: 'request-prompt:1', - kind: 'system-prompt', - id: '1', - target: 'chat', - anchorSeq: 1, - location: { kind: 'unresolved' }, - visibility: 'visible', - data: { text }, - } - const { container } = render() - - const disclosure = screen.getByRole('button', { name: 'System prompt' }) - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - expect(container.querySelector('[data-system-prompt-body]')).toBeNull() - expect(container.querySelector('[data-context-text]')).toBeNull() - - fireEvent.click(disclosure) - expect(disclosure.getAttribute('aria-expanded')).toBe('true') - expect(container.querySelector('[data-system-prompt-body]')).not.toBeNull() - expect(container.querySelector('[data-context-text]')?.textContent).toBe(text) - expect(screen.queryByRole('heading', { name: 'Agent rules' })).toBeNull() - - fireEvent.click(disclosure) - expect(disclosure.getAttribute('aria-expanded')).toBe('false') - expect(container.querySelector('[data-system-prompt-body]')).toBeNull() - }) - - it('titles an in-history prompt update as an update of the same row', () => { - const node: ChatNode<'system-prompt'> = { - key: 'system-message:10', - kind: 'system-prompt', - id: '10', - target: 'chat', - anchorSeq: 10, - location: { kind: 'unresolved' }, - visibility: 'visible', - data: { text: '# Updated rules', update: true }, - } - const { container } = render() - - const disclosure = screen.getByRole('button', { name: 'System prompt update' }) - fireEvent.click(disclosure) - expect(container.querySelector('[data-context-text]')?.textContent).toBe('# Updated rules') - }) -}) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 84717bb5c0..ac2aa97501 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -20,19 +20,19 @@ describe('TranscriptViewPolicy', () => { current = () => policy.mode.getSnapshot() expect(policy.mode.getSnapshot()).toBe('compact') - policy.setMode('normal') - expect(policy.mode.getSnapshot()).toBe('normal') - expect(observed).toEqual(['transcriptView=normal:normal']) - expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal') + policy.setMode('expanded') + expect(policy.mode.getSnapshot()).toBe('expanded') + expect(observed).toEqual(['transcriptView=expanded:expanded']) + expect(host.set).toHaveBeenCalledWith('transcriptView', 'expanded') }) it('adopts Host state and ignores identical writes', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(policy.mode.getSnapshot()).toBe('normal') - policy.setMode('normal') + host.publish({ status: 'ready', value: { transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(policy.mode.getSnapshot()).toBe('expanded') + policy.setMode('expanded') expect(host.set).not.toHaveBeenCalled() host.publish({ value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) @@ -40,8 +40,29 @@ describe('TranscriptViewPolicy', () => { }) it('adopts an accepted section standing at construction', () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true }) + expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('expanded') + }) + it.each([ + ['compact', 'compact'], + ['normal', 'detailed'], + ['detailed', 'detailed'], + ['expanded', 'expanded'], + ] as const)('reads saved %s as %s without rewriting settings', (saved, resolved) => { + const host = stubSettingsScope() + host.publish({ status: 'ready', value: { transcriptView: saved, performanceUsage: 'detailed' }, revision: 1, writable: true }) + const policy = new TranscriptViewPolicy(host.scope) + expect(policy.mode.getSnapshot()).toBe(resolved) + expect(host.set).not.toHaveBeenCalled() + }) + + it('persists Expanded only when explicitly selected after legacy Normal', () => { const host = stubSettingsScope() host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) - expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') + const policy = new TranscriptViewPolicy(host.scope) + policy.setMode('expanded') + expect(policy.mode.getSnapshot()).toBe('expanded') + expect(host.set).toHaveBeenCalledWith('transcriptView', 'expanded') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index f3ac40e704..3e112e94da 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -33,9 +33,9 @@ function noPendingInteraction() { // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] -function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { +function mount(mode: 'expanded' | 'detailed' | 'compact' = 'compact', dictionary: typeof en | typeof zh = en) { const source = createSnapshotStore(mode) - const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) }) + const setTranscriptView = vi.fn((next: 'expanded' | 'detailed' | 'compact') => { source.set(next) }) const props: TranscriptViewRowProps = { usePanelInfo: selector => selector({ activePanelId: null }), useSessions: emptySessions(), @@ -54,17 +54,18 @@ function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | t describe('TranscriptViewRow', () => { it('explains the preference and shows Compact by default', () => { mount() - expect(screen.getByText('Conversation display')).toBeDefined() - expect(screen.getByText('Controls process content in completed turns')).toBeDefined() + expect(screen.getByText('Work details')).toBeDefined() + expect(screen.getByText('Controls how turns and steps expand by default')).toBeDefined() expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false') }) - it('selects Normal and follows the mirrored value', () => { + it('selects Expanded and follows the mirrored value', () => { const b = mount() fireEvent.click(screen.getByRole('button', { name: /Compact/ })) - fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' })) - expect(b.setTranscriptView).toHaveBeenCalledWith('normal') - const trigger = screen.getByRole('button', { name: /Normal/ }) + expect(screen.getByRole('menuitem', { name: 'Detailed' })).toBeDefined() + fireEvent.click(screen.getByRole('menuitem', { name: 'Expanded' })) + expect(b.setTranscriptView).toHaveBeenCalledWith('expanded') + const trigger = screen.getByRole('button', { name: /Expanded/ }) fireEvent.click(trigger) expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined() fireEvent.pointerDown(document.body) @@ -73,9 +74,9 @@ describe('TranscriptViewRow', () => { it('shows the conversation-display values in Chinese', () => { mount('compact', zh) - fireEvent.click(screen.getByRole('button', { name: '紧凑' })) - fireEvent.click(screen.getByRole('menuitem', { name: '标准' })) - expect(screen.getByRole('button', { name: '标准' })).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: '简洁' })) + fireEvent.click(screen.getByRole('menuitem', { name: '完全展开' })) + expect(screen.getByRole('button', { name: '完全展开' })).toBeDefined() }) }) diff --git a/packages/client/ui-chat/tests/turn-process-view.client.spec.tsx b/packages/client/ui-chat/tests/turn-process-view.client.spec.tsx new file mode 100644 index 0000000000..aa9b2b4d1d --- /dev/null +++ b/packages/client/ui-chat/tests/turn-process-view.client.spec.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' +import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx' +import { en, zh } from '../src/client/locale.ts' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +const startedAt = new Date(2026, 8, 18, 12).getTime() + +function props(reason: 'running' | 'completed' | 'aborted' | 'error', dictionary = en, setOpen = vi.fn()): ChatNodeViewProps<'turn-process'> { + const spec = { + turn: 1, controlAnchorSeq: 1, processStartSeq: 2, answerAnchorSeq: null, + answerStep: null, inlineReasoning: false, messageCount: 1, toolCallCount: 0, subagentCount: 0, + } + return { + node: { + key: 'turn-process:1', kind: 'turn-process', id: '1', target: 'chat', anchorSeq: 1, + visibility: 'visible', data: spec, + location: { + kind: 'turn', + turn: { + turn: 1, status: reason === 'running' ? 'open' : 'closed', steps: [], + data: { get: () => undefined }, + start: { type: 'turn/start', seq: 1, time: startedAt, data: { turn: 1 } }, + end: reason === 'running' ? undefined : { + type: 'turn/end', seq: 3, time: startedAt + 5000, data: { turn: 1, reason: { kind: reason } }, + }, + }, + }, + }, + turnProcess: { spec, foldable: true, hasContent: true, open: true, setOpen }, + t: makeTranslate(dictionary), + } as unknown as ChatNodeViewProps<'turn-process'> +} + +it.each([ + { dictionary: en, running: 'Deep diving...', completed: 'Worked', aborted: 'Stopped', error: 'Failed' }, + { dictionary: zh, running: '深度求索中', completed: '已完成工作', aborted: '已停止', error: '处理失败' }, +])('announces lifecycle transitions independently of timer ticks in $running', ({ dictionary, running, ...labels }) => { + vi.useFakeTimers() + vi.setSystemTime(startedAt + 1000) + const view = render() + const status = view.getByRole('status') + expect(status.textContent).toBe(running) + expect(status.getAttribute('aria-live')).toBe('polite') + expect(status.getAttribute('aria-atomic')).toBe('true') + expect(status.closest('button')).toBeNull() + const before = view.getByRole('button').textContent + const observer = new MutationObserver(() => {}) + observer.observe(status, { childList: true, characterData: true, subtree: true }) + try { + act(() => { vi.advanceTimersByTime(2000) }) + expect(view.getByRole('button').textContent).not.toBe(before) + expect(view.getByRole('status')).toBe(status) + expect(status.textContent).toBe(running) + expect(observer.takeRecords()).toEqual([]) + for (const reason of ['completed', 'aborted', 'error'] as const) { + view.rerender() + expect(view.getByRole('status')).toBe(status) + expect(status.textContent).toBe(labels[reason]) + expect(vi.getTimerCount()).toBe(0) + } + } finally { + observer.disconnect() + } +}) + +it.each(['running', 'aborted', 'error'] as const)('keeps %s expanded, disabled, and without a disclosure arrow', (reason) => { + vi.useFakeTimers() + vi.setSystemTime(startedAt + 1000) + const setOpen = vi.fn() + const input = props(reason, en, setOpen) + const view = render() + const button = view.getByRole('button') as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(button.getAttribute('aria-expanded')).toBe('true') + expect(button.querySelector('svg')).toBeNull() + fireEvent.click(button) + expect(setOpen).not.toHaveBeenCalled() + view.unmount() + expect(vi.getTimerCount()).toBe(0) +}) + +it('keeps completed work collapsible with its elapsed time', () => { + const setOpen = vi.fn() + const input = props('completed', en, setOpen) + const view = render() + const button = view.getByRole('button', { name: 'Took 5s' }) as HTMLButtonElement + expect(button.disabled).toBe(false) + expect(button.querySelector('svg')).not.toBeNull() + fireEvent.click(button) + expect(setOpen).toHaveBeenCalledWith(false) +}) diff --git a/packages/client/ui-chat/tests/turn-trigger-view.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger-view.client.spec.tsx new file mode 100644 index 0000000000..fdf17216ab --- /dev/null +++ b/packages/client/ui-chat/tests/turn-trigger-view.client.spec.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { + IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular, + IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, + IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' +import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' +import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh, en } from '../src/client/locale.ts' + +const t = ((key: keyof typeof zh) => zh[key]) as ChatNodeViewProps['t'] + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +function props(source: unknown, text = 'trigger content'): ChatNodeViewProps<'turn-trigger'> { + return { + node: { + key: 'trigger:1', + kind: 'turn-trigger', + id: '1', + target: 'chat', + anchorSeq: 1, + location: { kind: 'session' }, + visibility: 'visible', + data: { + kind: 'context', + seq: 1, + time: 1, + source, + content: [{ type: 'text', text }], + producer: contextProducer(source), + form: contextForm(source), + }, + }, + t, + } as ChatNodeViewProps<'turn-trigger'> +} + +it.each([ + [{ kind: 'custom-extension' }, '', IconContextInjectionOutlineRegular], + [{ kind: 'goal' }, '', IconGoalOutlineRegular], + [{ kind: 'agent-message' }, '', IconPaperPlaneOutlineRegular], + [{ kind: 'team-message' }, '', IconAgentPresetOutlineRegular], + [{ kind: 'subagent-settled' }, '', IconAgentPresetOutlineRegular], + [{ kind: 'webhook', provider: 'github' }, '', IconBranchOutlineRegular], + [{ kind: 'webhook', provider: 'custom' }, '', IconGlobeOutlineRegular], + [{ kind: 'plugin', plugin: 'schedule' }, '', IconAlarmClockOutlineRegular], + [{ kind: 'plugin', plugin: 'tool-jobs' }, '', IconQueueOutlineRegular], + [{ kind: 'plugin', plugin: 'cordis-host-runner' }, '', IconCordisPluginOutlineRegular], +] as const)('renders the source-family icon for %o', (source, text, ExpectedIcon) => { + const actual = render() + .container.querySelector('[data-turn-trigger] button span svg')?.outerHTML + const expected = render().container.querySelector('svg')?.outerHTML + expect(actual).toBe(expected) +}) + +it('shows only the explanation and original model-facing content when expanded', () => { + const view = render() + + expect(view.queryByText('hidden summary')).toBeNull() + expect(view.queryByText('tool-jobs')).toBeNull() + fireEvent.click(view.getByRole('button')) + expect(view.getByText('这条通知触发了本轮回复。')).toBeTruthy() + expect(view.getByText('background job finished')).toBeTruthy() + expect(view.queryByText('hidden summary')).toBeNull() + expect(view.queryByText('tool-jobs')).toBeNull() + expect(view.container.querySelector('[data-context-fields]')).toBeNull() +}) + +it.each([ + { dictionary: zh, time: new Date(2026, 8, 18, 9, 5), expected: '09:05' }, + { dictionary: en, time: new Date(2026, 8, 18, 9, 5), expected: '09:05' }, + { dictionary: zh, time: new Date(2026, 8, 17, 9, 5), expected: '9月17日 09:05' }, + { dictionary: en, time: new Date(2026, 8, 17, 9, 5), expected: '9/17 09:05' }, + { dictionary: zh, time: new Date(2025, 11, 31, 9, 5), expected: '2025年12月31日 09:05' }, + { dictionary: en, time: new Date(2025, 11, 31, 9, 5), expected: '2025-12-31 09:05' }, +])('renders the localized trigger timestamp $expected', ({ dictionary, time, expected }) => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 8, 18, 12)) + const input = props({ kind: 'goal' }) + const node = { ...input.node, data: { ...input.node.data, time: time.getTime() } } + const view = render() + expect(view.getByText(expected).getAttribute('datetime')).toBe(time.toISOString()) +}) + + +it('preserves interleaved content and bounds long text inside a trigger notice', () => { + const input = props({ kind: 'goal' }) + const content = [ + { type: 'text', text: 'first' }, { type: 'text', text: 'second' }, + { type: 'future-block', payload: 'opaque' }, { type: 'text', text: 'x'.repeat(20_001) }, + ] as unknown as typeof input.node.data.content + const view = render() + fireEvent.click(view.getByRole('button')) + const texts = [...view.container.querySelectorAll('[data-context-text]')] + expect(texts[0]?.textContent).toBe('firstsecond') + expect(texts[1]?.textContent).toContain('x'.repeat(20_000)) + expect(texts[1]?.textContent).not.toContain('x'.repeat(20_001)) + expect(texts[1]?.textContent).toContain('20001') + expect(texts[0]?.nextElementSibling?.textContent).toContain('Unknown content block') + fireEvent.click(view.getByText('Unknown content block', { exact: false })) + expect(texts[0]?.nextElementSibling?.textContent).toContain('opaque') +}) diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.ts b/packages/client/ui-chat/tests/turn-trigger.client.spec.ts new file mode 100644 index 0000000000..fb945b0a00 --- /dev/null +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.ts @@ -0,0 +1,56 @@ +/** Source attribution and conservative parsing of waking notification titles. */ +import { expect, it } from 'vitest' +import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' +import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts' + +function details(source: unknown, text: string) { + return turnTriggerDetails({ kind: 'context', seq: 1, time: 1, source, + content: [{ type: 'text', text }], producer: contextProducer(source), form: contextForm(source) }) +} + +it.each(['completed', 'failed', 'killed', 'future'])('uses the durable job source for status %s', (status) => { + expect(details({ kind: 'plugin', plugin: 'tool-jobs' }, + `background job bash-39 (bash: PR merge check) finished [status: ${status}, exit code 0].`)) + .toEqual({ title: 'message.trigger.job', icon: 'job' }) +}) + +it('classifies schedule and goal triggers', () => { + expect(details({ kind: 'plugin', plugin: 'schedule' }, 'reminder_prompt_json: "检查合并"')) + .toEqual({ title: 'message.trigger.schedule', icon: 'schedule' }) + expect(details({ kind: 'goal', goalId: 'g1' }, 'Objective: "完成测试"')) + .toEqual({ title: 'message.trigger.goal', icon: 'goal' }) +}) + +it.each([ + 'Cordis run plug/pkg (r1) completed successfully.', + 'The user rejected Cordis run plug/pkg (r1).', + 'Cordis update plug/pkg (r1) failed after cordis_run returned starting: broken', + 'Cordis Client guard rejected runtime code in plug/pkg (r1).', +])('uses the durable Cordis source independently of producer text %s', (text) => { + expect(details({ kind: 'plugin', plugin: 'cordis-host-runner' }, text).title).toBe('message.trigger.plugin') +}) + +it('uses the durable subagent source independently of settlement wording', () => { + expect(details({ kind: 'subagent-settled', senderSessionId: 'child-1' }, 'future settlement wording')) + .toEqual({ title: 'message.trigger.subagent', icon: 'subagent' }) +}) + +it('keeps unknown sources and unrecognized status neutral', () => { + expect(details({ kind: 'custom-extension' }, 'completed successfully')).toMatchObject({ + title: 'message.trigger.request', icon: 'request', + }) + expect(details({ kind: 'plugin', plugin: 'tool-jobs', summary: 'job update' }, 'future notice format')) + .toEqual({ title: 'message.trigger.job', icon: 'job' }) +}) + +it.each([ + [{ kind: 'agent-message', senderSessionId: 'agent-1' }, '', 'agent'], + [{ kind: 'team-message', senderName: 'Reviewer' }, '', 'team'], + [{ kind: 'subagent-settled', senderSessionId: 'child-1' }, '', 'subagent'], + [{ kind: 'webhook', provider: 'github', ruleId: 'review' }, '', 'github'], + [{ kind: 'webhook', provider: 'custom', ruleId: 'deploy' }, '', 'webhook'], + [{ kind: 'plugin', plugin: 'tool-jobs' }, 'future notice format', 'job'], + [{ kind: 'plugin', plugin: 'cordis-host-runner' }, 'future notice format', 'plugin'], +] as const)('selects the source-family icon for %o', (source, text, icon) => { + expect(details(source, text).icon).toBe(icon) +}) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 464ff55e17..c164dc8535 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -286,7 +286,7 @@ export const zh = { 'row.inspect': '查看', 'tool.title.search': '搜索', 'tool.title.read': '读取', - 'tool.title.bash': 'Bash', + 'tool.title.bash': '运行命令', 'tool.title.write': '写入', 'tool.title.edit': '编辑', 'tool.title.code': '代码', @@ -295,10 +295,10 @@ export const zh = { 'tool.title.runCordis': '运行 Cordis 插件', 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', - 'tool.title.pwsh': 'Pwsh', + 'tool.title.pwsh': '运行命令', 'tool.title.readImage': '读取图片', - 'tool.title.grep': 'Grep', - 'tool.title.glob': 'Glob', + 'tool.title.grep': '搜索文件内容', + 'tool.title.glob': '查找文件', 'tool.title.webSearch': '网页搜索', 'tool.title.webFetch': '网页获取', 'tool.autoReviewRejected': 'Auto review 已拒绝', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f5687d8269..423fd9fd19 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 09c56614799b30c2c6829620bbf14d7cc55cb0b0 -README.zh.md: ced758f9b43605365b43a62363bac0b0ca8a6df2 +README.md: e15a7dd1786e98b872bfab5719589903cec40554 +README.zh.md: 7e2a4d4a94e47c112512dcdba8bd788f372336df diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 09c5661479..e15a7dd178 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -37,7 +37,7 @@ Check this table before writing a control in a feature package. A plugin cannot | Export | What it is | |---|---| | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. | -| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. | +| `Switch` | Two-state toggle, 36×20. Its thumb is white while off and uses the active track's foreground color while on. `label` is required, so the control cannot ship unnamed. | | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. | | `Menu` | Dropdown of items, separators, and group labels, with nested submenus. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. | @@ -45,7 +45,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. | | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | -| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. | +| `DisclosureRow` | 24px compact disclosure that lays title and content side by side; collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. | +| `TextShimmer` | Animated text shimmer whose default 1.5-second cycle moves a lighter band with ease-in-out timing for one second, then pauses for 500ms; cycle duration, character-relative spread, base color, and shimmer color are configurable, and reduced-motion mode keeps the static base. | | `Modal` | Centered dialog over a page mask. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index ced758f9b4..7e2a4d4a94 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -37,7 +37,7 @@ kind: "package-library" | 导出 | 是什么 | |---|---| | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 | -| `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 | +| `Switch` | 36×20 的双态开关。关闭时圆点为白色,开启时使用激活轨道的前景色。`label` 必填,控件不可能在没有名称的情况下发布。 | | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。 | | `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 | @@ -45,7 +45,8 @@ kind: "package-library" | `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 | | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | -| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 | +| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列;收起时悬停预览向下箭头,展开后持续显示向上箭头。 | +| `TextShimmer` | 默认底色与结束态继承颜色一致;默认 1.5 秒周期中,较浅色带以 ease-in-out 曲线移动一秒,再停顿 500ms。周期、按字符长度计算的扫光宽度、底色与扫光色均可配置,减少动态效果模式保留静态底色。 | | `Modal` | 页面遮罩之上的居中对话框。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f79b7e4720..747f8a3c4a 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,18 +1,18 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutlineRegular } from './icons/index.tsx' +import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' /** Shared 24px disclosure chrome for compact flow rows. */ export interface DisclosureRowProps { icon: ReactNode - title: string + title: ReactNode open: boolean expandable: boolean onToggle: () => void /** Makes the complete title row the disclosure target. */ expandOnRowClick?: boolean | undefined - /** Replaces the collapsed icon with a chevron while the row is hovered. */ + /** Replaces the collapsed icon with a down chevron while the row is hovered. */ previewChevron?: boolean | undefined /** Keeps `collapsedContent` inline while open. */ keepContentWhenOpen?: boolean | undefined @@ -66,7 +66,7 @@ export function DisclosureRow({ ) : icon const leading = open - ? + ? : collapsedLeading return ( diff --git a/packages/client/ui-primitives/src/Switch.tsx b/packages/client/ui-primitives/src/Switch.tsx index 980a08f6be..d7d8c89f8e 100644 --- a/packages/client/ui-primitives/src/Switch.tsx +++ b/packages/client/ui-primitives/src/Switch.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx' import css from './Switch.module.css' /** - * Render a toggle switch. + * Render a toggle switch with a white resting thumb and an active-track foreground thumb. * @param props.checked - the current state; the control is fully controlled. * @param props.onChange - called with the state the click asks for. * @param props.label - localized accessible name, owned by the render site. diff --git a/packages/client/ui-primitives/src/TextShimmer.module.css b/packages/client/ui-primitives/src/TextShimmer.module.css new file mode 100644 index 0000000000..b1c4497232 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.module.css @@ -0,0 +1,36 @@ +.root { + position: relative; + display: inline-block; + background-image: + linear-gradient( + 90deg, + var(--dsh-text-shimmer-base-color) calc(50% - var(--dsh-text-shimmer-spread)), + var(--dsh-text-shimmer-color), + var(--dsh-text-shimmer-base-color) calc(50% + var(--dsh-text-shimmer-spread)) + ); + background-position: 100% center; + background-repeat: no-repeat; + background-size: 250% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation-name: dsh-text-shimmer; + animation-duration: var(--dsh-text-shimmer-duration); + animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1); + animation-iteration-count: infinite; +} + +@keyframes dsh-text-shimmer { + 66.6667%, + 100% { + background-position: 0% center; + } +} + +@media (prefers-reduced-motion: reduce) { + .root { + background-image: linear-gradient(var(--dsh-text-shimmer-base-color), var(--dsh-text-shimmer-base-color)); + background-size: 100% 100%; + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/TextShimmer.tsx b/packages/client/ui-primitives/src/TextShimmer.tsx new file mode 100644 index 0000000000..ab4dd310f2 --- /dev/null +++ b/packages/client/ui-primitives/src/TextShimmer.tsx @@ -0,0 +1,51 @@ +import { memo, useMemo, type CSSProperties, type ElementType } from 'react' +import clsx from 'clsx' +import css from './TextShimmer.module.css' + +/** Text-only shimmer animation shared by live process and tool labels. */ +export interface TextShimmerProps { + /** Visible text whose length determines the highlight width. */ + children: string + /** Element used for the text wrapper. */ + as?: ElementType | undefined + /** Additional class applied to the wrapper. */ + className?: string | undefined + /** Complete animation cycle in seconds; defaults to 1.5 seconds. */ + duration?: number | undefined + /** Moving-band width multiplier in pixels per character; defaults to eight. */ + spread?: number | undefined + /** Text color outside the moving band; defaults to the inherited color. */ + baseColor?: string | undefined + /** Moving band color; defaults to a translucent form of the inherited color. */ + shimmerColor?: string | undefined + /** Additional inline styles. */ + style?: CSSProperties | undefined +} + +function TextShimmerComponent({ + children, + as: Component = 'span', + className, + duration = 1.5, + spread = 8, + baseColor, + shimmerColor, + style, +}: TextShimmerProps) { + const dynamicSpread = useMemo(() => children.length * spread, [children, spread]) + const shimmerStyle = { + ...style, + '--dsh-text-shimmer-duration': `${duration}s`, + '--dsh-text-shimmer-spread': `${dynamicSpread}px`, + '--dsh-text-shimmer-base-color': baseColor ?? 'currentColor', + '--dsh-text-shimmer-color': shimmerColor ?? 'color-mix(in oklab, currentColor 50%, transparent)', + } as CSSProperties + return ( + + {children} + + ) +} + +/** Memoized text shimmer with a duration and band width derived from its props. */ +export const TextShimmer = memo(TextShimmerComponent) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index ba649aeb20..a40b6ca716 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -6,6 +6,8 @@ export { StateDot } from './StateDot.tsx' export type { StateDotState } from './StateDot.tsx' export { DisclosureRow } from './DisclosureRow.tsx' export type { DisclosureRowProps } from './DisclosureRow.tsx' +export { TextShimmer } from './TextShimmer.tsx' +export type { TextShimmerProps } from './TextShimmer.tsx' export { Button } from './Button.tsx' export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' diff --git a/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx new file mode 100644 index 0000000000..59c80d6f76 --- /dev/null +++ b/packages/client/ui-primitives/tests/text-shimmer.client.spec.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { TextShimmer } from '../src/TextShimmer.tsx' + +describe('TextShimmer', () => { + it('keeps the settled text color while a lighter band moves across it', () => { + const view = render(Thinking) + const shimmer = view.getByText('Thinking') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-duration')).toBe('1.5s') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('64px') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-base-color')).toBe('currentColor') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-color')).toBe('color-mix(in oklab, currentColor 50%, transparent)') + }) + + it('derives the band width and exposes configurable animation colors', () => { + const view = render( + + Working + , + ) + const shimmer = view.getByText('Working') + expect(shimmer.tagName).toBe('STRONG') + expect(shimmer.getAttribute('data-text-shimmer')).toBe('') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-duration')).toBe('3s') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('28px') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-base-color')).toBe('gray') + expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-color')).toBe('white') + }) +}) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 1d11e8ea30..ae974a73f3 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -107,7 +107,7 @@ describe('ui-settings-general apply', () => { const developerRow = c.ctx.slots.entries('settings.general.item').find(row => row.options.id === 'developer-tools')! const developer = (developerRow.inject as unknown as () => DeveloperToolsRowInjected)() expect(developer.hooks.developerTools).toBe(c.ctx.settingsScope.developerTools.enabled) - expect(developer.hooks.developerTools.getSnapshot()).toBe(false) + expect(developer.hooks.developerTools.getSnapshot()).toBe(true) const setEnabled = vi.spyOn(c.ctx.settingsScope.developerTools, 'setEnabled').mockResolvedValue(undefined) await developer.setEnabled(true) expect(setEnabled).toHaveBeenCalledExactlyOnceWith(true) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 1b479691fd..bcc65b6ca3 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c -README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044 +README.md: 83ded1195fd1278245eec93e8244744ffa946238 +README.zh.md: 39c72a51fda0adf01199bb57cf446af2ca5dbfbf diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index d70cb35878..83ded1195f 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. +`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 87687ac3cc..39c72a51fd 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 +`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 diff --git a/packages/client/ui-settings/src/client/developer-tools.ts b/packages/client/ui-settings/src/client/developer-tools.ts index 22e1e8de3d..51466d878f 100644 --- a/packages/client/ui-settings/src/client/developer-tools.ts +++ b/packages/client/ui-settings/src/client/developer-tools.ts @@ -3,18 +3,18 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c import type { DeveloperToolsSettings } from '../developer-tools-settings.ts' import type { SettingsScope } from './settings-contract.ts' -/** Shared preference; no accepted value means developer tools are disabled. */ +/** Shared preference; no accepted value means developer tools are enabled. */ export class DeveloperToolsPreference { /** Accepted enablement, observable through renderer-bound hooks. */ readonly enabled: ObservableSnapshot - private readonly local = createSnapshotStore(false) + private readonly local = createSnapshotStore(true) /** * @param scope - settings-owned namespace controller. */ constructor(private readonly scope: SettingsScope) { this.enabled = scope.getSnapshot().mode === 'memory' ? this.local : { - getSnapshot: () => scope.getSnapshot().value?.enabled ?? false, + getSnapshot: () => scope.getSnapshot().value?.enabled ?? true, subscribe: (listener) => { let previous = this.enabled.getSnapshot() return scope.subscribe(() => { diff --git a/packages/client/ui-settings/src/developer-tools-settings.ts b/packages/client/ui-settings/src/developer-tools-settings.ts index e7a64e0e26..16f1cce3e6 100644 --- a/packages/client/ui-settings/src/developer-tools-settings.ts +++ b/packages/client/ui-settings/src/developer-tools-settings.ts @@ -10,7 +10,7 @@ export interface DeveloperToolsSettings { enabled: boolean } -/** New installations and missing values use the basic interface. */ +/** New installations and missing values enable developer tools. */ export const DeveloperToolsSettingsSchema: z = z.object({ - enabled: z.boolean().default(false), + enabled: z.boolean().default(true), }) diff --git a/packages/client/ui-settings/tests/developer-tools.client.spec.ts b/packages/client/ui-settings/tests/developer-tools.client.spec.ts index 3a68290c19..ebf89c2ef9 100644 --- a/packages/client/ui-settings/tests/developer-tools.client.spec.ts +++ b/packages/client/ui-settings/tests/developer-tools.client.spec.ts @@ -55,7 +55,7 @@ describe('developer tools settings', () => { await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() }) - it('defaults off, persists valid choices and removes its schema on disposal', async () => { + it('defaults on, persists valid choices and removes its schema on disposal', async () => { const ctx = new Context() const provider = ctx.plugin(MemorySettings) onTestFinished(() => provider.dispose()) @@ -63,27 +63,27 @@ describe('developer tools settings', () => { const fiber = ctx.plugin({ apply }) onTestFinished(() => fiber.dispose()) await fiber.await() - expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false }) - await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true }) expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true }) + await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: false }) + expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false }) await expect(ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: 'yes' })).rejects.toThrow() await fiber.dispose() expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE) }) - it('stays off until accepted settings arrive and follows external changes', async () => { + it('stays on until accepted settings arrive and follows external changes', async () => { const host = stubSettingsScope() const preference = new DeveloperToolsPreference(host.scope) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) - expect(preference.enabled.getSnapshot()).toBe(false) - host.publish({ status: 'ready', value: { enabled: true } }) expect(preference.enabled.getSnapshot()).toBe(true) - expect(notify).toHaveBeenCalledOnce() - await preference.setEnabled(false) - expect(host.set).toHaveBeenCalledWith('enabled', false) - host.publish({ value: { enabled: false } }) + host.publish({ status: 'ready', value: { enabled: false } }) expect(preference.enabled.getSnapshot()).toBe(false) + expect(notify).toHaveBeenCalledOnce() + await preference.setEnabled(true) + expect(host.set).toHaveBeenCalledWith('enabled', true) + host.publish({ value: { enabled: true } }) + expect(preference.enabled.getSnapshot()).toBe(true) dispose() }) }) @@ -94,13 +94,13 @@ it('keeps remote browser choices local and publishes only changed values', async const preference = new DeveloperToolsPreference(host.scope) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) - expect(preference.enabled.getSnapshot()).toBe(false) - await preference.setEnabled(true) expect(preference.enabled.getSnapshot()).toBe(true) - await preference.setEnabled(true) - expect(notify).toHaveBeenCalledOnce() await preference.setEnabled(false) expect(preference.enabled.getSnapshot()).toBe(false) + await preference.setEnabled(false) + expect(notify).toHaveBeenCalledOnce() + await preference.setEnabled(true) + expect(preference.enabled.getSnapshot()).toBe(true) expect(host.set).not.toHaveBeenCalled() dispose() }) @@ -111,9 +111,9 @@ it('ignores host revisions that do not change enablement', () => { const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) host.publish({ revision: 1 }) - host.publish({ value: { enabled: false } }) - expect(notify).not.toHaveBeenCalled() host.publish({ value: { enabled: true } }) + expect(notify).not.toHaveBeenCalled() + host.publish({ value: { enabled: false } }) expect(notify).toHaveBeenCalledOnce() dispose() }) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 83f8c75b1c..8534b3f957 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: b2687d59fc6566d9ea8a0c0d4d5b16eea38282b5 -README.zh.md: 7e6c8a7ecee2fe10d5826b91c108733bb4d3746f +README.md: 1bb4744301b54c26e9b1ea23994af4f0608af2df +README.zh.md: e2fe646b877c9eac565cf514af852d0c88d197ff diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index b2687d59fc..1bb4744301 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -33,7 +33,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves ### The skill tool row -A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. +A collapsed row renders the skill glyph, localized title (`加载技能` in Chinese, `Skill` in English), and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 7e6c8a7ece..e2fe646b87 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### skill 工具行 -收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +收起的行显示 skill 图标、本地化标题(中文为 `加载技能`,英文为 `Skill`)与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断的调用使用警告状态。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。 diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index d7a9b94228..ebd0910324 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -5,7 +5,7 @@ export const NS = 'skill' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'row.title': 'Skill', + 'row.title': '加载技能', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 189d961bcf..46d6f3e9b1 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -125,7 +125,7 @@ describe('apply', () => { expect(presentation.dictionaries).toEqual([{ namespace: 'skill', dictionaries: { zh: { - 'row.title': 'Skill', + 'row.title': '加载技能', 'row.running': '正在加载 skill', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index da55917f84..e6f4f6c1dd 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -6,7 +6,7 @@ import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { SkillRow } from '../src/client/SkillRow.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' type SkillRowProps = Parameters[0] @@ -48,10 +48,18 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro } describe('SkillRow', () => { + it('switches the title with the interface locale while preserving the skill name', () => { + const owner = props(settled()) + const view = render() + expect(view.getByRole('button', { name: '加载技能dsh-manage-issues' })).toBeTruthy() + view.rerender() + expect(view.getByRole('button', { name: 'Skilldsh-manage-issues' })).toBeTruthy() + }) + it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { const inspect = vi.fn() const view = render() - const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' }) + const row = screen.getByRole('button', { name: '加载技能dsh-manage-issues' }) expect(row.getAttribute('aria-expanded')).toBe('false') expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok') expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14') @@ -95,7 +103,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' }) + const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() @@ -128,7 +136,7 @@ describe('SkillRow', () => { isError: true, error: { name: 'SkillError', code: 'missing' }, }))} />) - const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' }) + const errorRow = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing' }) fireEvent.click(errorRow) expect(screen.getAllByText('SkillError: missing')).toHaveLength(2) }) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ce636be50c..5639c2d7f4 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 6a63c14d163742b1818320925248405831bd87b6 -README.zh.md: e53a97404ae17f0e93242c11dcc0b1f066542c12 +README.md: 74afdaa1cb752bc273f36362b866a68eb1ca2479 +README.zh.md: 1bf843566671588186112dfba1f708b846578540 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 6a63c14d16..74afdaa1cb 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### Built-in views -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 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, while background acknowledgements remain collapsed. 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. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result 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 animate their title and summary with the shared text shimmer, and hover darkens that text; settled rows remain static. Expandable rows preview a down chevron while collapsed and keep an up chevron visible while open. 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, while background acknowledgements remain collapsed. 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. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -109,7 +109,7 @@ These limits define the dispatch depth and the view ownership; they are current - **The Host excludes `run_code` from PTC mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. - **First-party Tool views are colocated here** — they can move to their owning business packages independently through the keyed slot. -- **Tool copy reuses the `ui-conversation` locale namespace** — tool titles, row chrome, and Cordis-free primitive labels use that dictionary; presenter models retain locale keys or data rather than rendered wording. +- **Tool copy reuses the `ui-conversation` locale namespace** — tool titles, row chrome, and Cordis-free primitive labels use that dictionary; presenter models retain locale keys or data rather than rendered wording. Titles follow the interface language; model-authored summaries retain their generated language. ### Dev Note diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index e53a97404a..1bf8435666 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -本包拥有 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 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`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 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行使用共享文字扫光动画显示标题与摘要,悬停时文字颜色加深;已完成行保持静态。可展开行在收起时预览向下箭头,展开后持续显示向上箭头。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -109,7 +109,7 @@ terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice - **Host 不把 `run_code` 暴露为 PTC mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 - **第一方工具视图集中在本包**:它们可以通过 keyed slot 独立迁移到各自所属的业务包。 -- **工具文案复用 `ui-conversation` locale namespace**:工具标题、行 chrome 与无 Cordis 的 primitive label 使用该字典;展示转换器模型保留 locale key 或数据,而不是已渲染文案。 +- **工具文案复用 `ui-conversation` locale namespace**:工具标题、行 chrome 与无 Cordis 的 primitive label 使用该字典;展示转换器模型保留 locale key 或数据,而不是已渲染文案。标题跟随界面语言;模型生成的摘要保留生成时的语言。 ### 开发备注 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 520e800a3a..3bf04ae4ac 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -11,37 +11,6 @@ flex-direction: column; } -.row { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; -} - -/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — - theme background at 60% — glides over the row content from off-left to - off-right, washing glyphs and icon toward the background as it passes. - ease-out with a 10% end hold gives each pass a beat before the next. */ -.root[data-state='running'] .row::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-tool-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-tool-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { flex-shrink: 0; } @@ -67,6 +36,7 @@ .title { font-weight: 400; + transition: color 100ms ease; } .sep { @@ -87,6 +57,7 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; } /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose @@ -101,6 +72,13 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.row:hover .title, +.row:hover .summary:not(.errorSummary), +.row:hover .summarySuffix { + color: var(--dsw-alias-label-primary); } /* Diff-row +/- totals: the code font, matching the diff body the numbers @@ -143,8 +121,10 @@ text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: pointer; + transition: color 100ms ease; } +.row:hover .fileLink, .fileLink:hover { color: var(--dsw-alias-label-primary); text-decoration-color: currentColor; @@ -360,8 +340,9 @@ border: 0.5px solid var(--dsw-alias-border-l1); } -/* Visually hidden run-state label for assistive technology: the running sweep - and settlement summary tones are color-only. */ +/* Visually hidden run-state label for assistive technology: the business icon + and animated text treatment do not name the state, so this text carries the + running/failed/interrupted state to a screen reader. */ .visuallyHidden { position: absolute; width: 1px; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 3799370527..8a29cc209c 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -2,8 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, - TerminalBlock, WebBlock, - diffTotals, + TerminalBlock, TextShimmer, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -91,7 +90,9 @@ export interface ToolRowProps { inspect?: (() => void) | undefined } -/** Visually hidden run-state label for color-only running and settlement cues. */ +/** Visually hidden run-state label: the business icon and animated text treatment + * do not name the lifecycle state, so assistive technology needs this text to + * identify running, failed, or interrupted rows. */ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null { switch (state) { case 'running': return t('row.running') @@ -101,6 +102,10 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin } } +function liveText(text: string, running: boolean): ReactNode { + return running ? {text} : text +} + export function ToolRow({ t, variant, @@ -157,6 +162,7 @@ export function ToolRow({ ) const status = stateStatus(state, t) const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) + const running = state === 'running' // A failure keeps its first result line when available and otherwise turns // the ordinary summary red. An interruption turns the tool-owned summary // amber while retaining the business icon and hidden state announcement. @@ -201,7 +207,7 @@ export function ToolRow({ titleClassName={css.title} chevronClassName={css.chevron} icon={icon} - title={title} + title={liveText(title, running)} open={open} expandable={expandable} expandOnRowClick @@ -219,7 +225,7 @@ export function ToolRow({ onClick={openFile} onKeyDown={fileLinkKeyDown} > - {summaryText} + {liveText(summaryText, running)} ) : ( - {summaryText} + {liveText(summaryText, running)} )} {suffix !== null && ( - {suffix} + + {liveText(suffix, running)} + )} )} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index ab8acd692c..be9b547ce6 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -85,37 +85,12 @@ } .root { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; display: flex; align-items: center; height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; } -/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ -.root[data-state='running']::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-bash-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-bash-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - .leading { position: relative; /* .chevronHover overlay anchor */ flex: none; @@ -172,6 +147,7 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); + transition: color 100ms ease; } .sep { @@ -192,6 +168,12 @@ font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); + transition: color 100ms ease; +} + +.root:hover .title, +.root:hover .summary:not(.errorSummary) { + color: var(--dsw-alias-label-primary); } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 7b1f8653c5..86eaaee86d 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -2,7 +2,8 @@ import { useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { - IconApiOutlineRegular, IconChevronDownOutlineRegular, IconInspectOutlineRegular, TerminalBlock, + IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, + TerminalBlock, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -20,15 +21,20 @@ import css from './bash-sample.module.css' type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'> -/** Visually hidden status for the color-only running sweep and error tone. */ +/** Visually hidden status for the business icon and animated text treatment. */ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { switch (state) { case 'running': return t('bash.running') case 'error': return t('bash.failed') + case 'stopped': return t('bash.stopped') default: return null } } +function liveText(text: string, running: boolean) { + return running ? {text} : text +} + /** Renders expandable Bash output with an accessible lifecycle label. */ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { const model = toolRowModel(toolName, block) @@ -61,6 +67,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: const settlementLine = state === 'error' ? model.errorSummary ?? normalSummary : state === 'stopped' ? t('bash.stopped') : null + const running = state === 'running' const toggleExpand = () => { setExpanded(v => !v) } @@ -71,7 +78,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: } const businessIcon = const leading = open - ? + ? : expandable ? ( <> @@ -96,14 +103,14 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: > {leading} {status !== null && {status}} - {t(model.titleKey)} + {liveText(t(model.titleKey), running)} - {settlementLine ?? normalSummary} + {liveText(settlementLine ?? normalSummary, running)}
{open && ( diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 54212fa981..3127d9b086 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -258,10 +258,11 @@ describe('run_code sub-calls through the real chat machinery', () => { } const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) const view = mountApp(b.runtime) - // The nested row derives 'running' from the RunningToolCall shape — the - // same data-state chrome (row sweep) a native in-flight row wears. + // The nested row derives 'running' from the RunningToolCall shape and + // receives the same text shimmer as a native in-flight row. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') expect(nested).not.toBeNull() + expect(nested!.querySelector('[data-text-shimmer]')).not.toBeNull() }) it('an ordinary tool row renders no sub-call nest', async () => { diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 2c0fe097a2..6b715bf6b1 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -76,7 +76,7 @@ describe('Tool presentation tails', () => { } const view = render() const row = view.container.querySelector('[data-sample="bash"]')! - expect(row.textContent).toContain('Bash') + expect(row.textContent).toContain('运行命令') expect(row.textContent).toContain('Build') expect(row.getAttribute('data-clickable')).toBeNull() }) @@ -99,7 +99,8 @@ describe('Tool presentation tails', () => { const runningView = render() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() - expect(runningView.getByText('Bash')).toBeTruthy() + expect(runningView.container.querySelectorAll('[data-text-shimmer]')).toHaveLength(2) + expect(runningView.getByText('运行命令')).toBeTruthy() expect(runningView.getByText('List')).toBeTruthy() runningView.unmount() @@ -107,14 +108,15 @@ describe('Tool presentation tails', () => { expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"] svg')).not.toBeNull() - expect(errorView.getByText('Bash')).toBeTruthy() + expect(errorView.container.querySelector('[data-text-shimmer]')).toBeNull() + expect(errorView.getByText('运行命令')).toBeTruthy() + expect(errorView.getByText('失败')).toBeTruthy() expect(errorView.container.querySelector('[class*="_errorSummary_"]')).not.toBeNull() errorView.unmount() const stoppedView = render() expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() expect(stoppedView.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() - const stoppedSummary = stoppedView.getByText('已停止') - expect(stoppedSummary.className).toContain('stoppedSummary') + expect(stoppedView.container.querySelector('[class*="_stoppedSummary_"]')?.textContent).toBe('已停止') }) }) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index ca8e8bde20..cee488b8b6 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -236,7 +236,7 @@ describe('SearchRow keyed card', () => { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - expect(view.getByText('Grep')).toBeTruthy() + expect(view.getByText('搜索文件内容')).toBeTruthy() expect(view.queryByText('Search')).toBeNull() // Collapsed: the card is not in the DOM until the row is expanded. expect(searchKindOf(view.container)).toBeNull() @@ -250,7 +250,7 @@ describe('SearchRow keyed card', () => { it('expands to the glob path card', () => { const view = render() - expect(view.getByText('Glob')).toBeTruthy() + expect(view.getByText('查找文件')).toBeTruthy() expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index cb95b6fcf6..8fa1584353 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -471,6 +471,8 @@ describe('BashRow terminal card', () => { fireEvent.click(row) expect(row.getAttribute('aria-expanded')).toBe('true') + expect(row.querySelector('svg path')?.getAttribute('d')) + .toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10') expect(view.getByText('输入')).toBeTruthy() expect(view.getByText('输出')).toBeTruthy() expect(view.getByText(/"command": "ls -la"/)).toBeTruthy() diff --git a/packages/client/ui-tool/tests/tool-fixtures.client.ts b/packages/client/ui-tool/tests/tool-fixtures.client.ts index dfb73e25a5..45e6334e5b 100644 --- a/packages/client/ui-tool/tests/tool-fixtures.client.ts +++ b/packages/client/ui-tool/tests/tool-fixtures.client.ts @@ -30,6 +30,10 @@ export function toolChatSnapshot( const byKey = new Map(nodes.map(node => [node.key, node])) const empty: readonly string[] = [] return { + stepProcesses: { + layout: nodes.map(node => ({ key: node.key, seats: [{ nodeKey: node.key }], process: false, turn: undefined })), + get: () => undefined, footer: () => undefined, + }, order: nodes.map(node => node.key), nodes: { get: key => byKey.get(key), diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 6ef6cd713a..57793af48d 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -11,7 +11,7 @@ import { } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' -import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(() => { cleanup() @@ -33,6 +33,17 @@ const result = (over?: Partial): ToolResultNode => ({ }) describe('tool-call-model', () => { + it.each([ + ['tool.title.bash', '运行命令', 'Bash'], + ['tool.title.pwsh', '运行命令', 'Pwsh'], + ['tool.title.grep', '搜索文件内容', 'Grep'], + ['tool.title.glob', '查找文件', 'Glob'], + ] as const)('localizes %s without translating generated summaries', (key, chinese, english) => { + expect(t(key)).toBe(chinese) + expect(makeTranslate(en)(key)).toBe(english) + expect(toolRowModel('bash', running()).summary).toBe('List files') + }) + it('classifies known tools and falls back to others', () => { expect(classifyTool('bash')).toBe('bash') expect(classifyTool('pwsh')).toBe('bash') @@ -84,7 +95,8 @@ describe('tool-call-model', () => { it('gives the pwsh shell row the bash family treatment with its own title', () => { const m = toolRowModel('pwsh', running()) expect(m.variant).toBe('bash') - expect(t(m.titleKey)).toBe('Pwsh') + expect(m.titleKey).toBe('tool.title.pwsh') + expect(t(m.titleKey)).toBe('运行命令') }) it('derives state across running/ok/error/interrupted', () => { @@ -96,7 +108,7 @@ describe('tool-call-model', () => { it('derives the bash summary from description over command', () => { const m = toolRowModel('bash', running()) - expect(t(m.titleKey)).toBe('Bash') + expect(t(m.titleKey)).toBe('运行命令') expect(m.summary).toBe('List files') expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' })).summary).toBe('pwd') }) @@ -290,7 +302,8 @@ describe('ToolRow', () => { const view = render() fireEvent.click(view.getByRole('button')) expect(view.queryByTestId('tool-icon')).toBeNull() - expect(view.container.querySelector('svg')).not.toBeNull() + expect(view.container.querySelector('svg path')?.getAttribute('d')) + .toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10') expect(view.getByText('List files')).toBeTruthy() expect(view.getByText(/"a": 1/)).toBeTruthy() expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull() @@ -340,12 +353,15 @@ describe('ToolRow', () => { expect(view.queryByText(/"a": 1/)).toBeNull() }) - it('keeps the business icon across running and error states', () => { + it('keeps the business icon while only running text shimmers', () => { const runningView = render() expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + expect(runningView.container.querySelectorAll('[data-text-shimmer]')).toHaveLength(2) const errorView = render() expect(errorView.container.querySelector('[data-testid="tool-icon"]')).not.toBeNull() + expect(errorView.container.querySelector('span[data-state="error"]')).toBeNull() + expect(errorView.container.querySelector('[data-text-shimmer]')).toBeNull() expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull() }) @@ -499,7 +515,7 @@ describe('GenericToolCard', () => { it('renders the classified variant row from the frozen slice', () => { const view = render() - expect(view.getByText('Bash')).toBeTruthy() + expect(view.getByText('运行命令')).toBeTruthy() expect(view.getByText('List files')).toBeTruthy() expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull() }) @@ -556,7 +572,7 @@ describe('GenericToolCard', () => { it('passes the owner inspect callback through to the expanded row pill', () => { const inspect = vi.fn() const view = render() - fireEvent.click(view.getByRole('button', { name: /Bash/ })) + fireEvent.click(view.getByRole('button', { name: /运行命令/ })) fireEvent.click(view.getByText('查看')) expect(inspect).toHaveBeenCalledTimes(1) }) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 29770236b8..535191d1ab 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -70,6 +70,7 @@ const conversationState: ConversationState = { const emptyKeys: readonly string[] = [] const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} } const chatState: ChatState = { + stepProcesses: { layout: [], get: () => undefined, footer: () => undefined }, order: emptyKeys, nodes: { get: () => undefined, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index b83eb16262..c976e8e0b5 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -68,6 +68,7 @@ const conversationState: ConversationState = { const emptyKeys: readonly string[] = [] const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} } const chatState: ChatState = { + stepProcesses: { layout: [], get: () => undefined, footer: () => undefined }, order: emptyKeys, nodes: { get: () => undefined, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index f35e6303a2..c623caa2c1 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:232', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:220', }, { key: 'conversation.chat.node', @@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Hide settled reasoning previews in Compact work details. */\n readonly compactTranscript?: boolean\n\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -268,15 +268,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, manual-compaction, model-retry, steering, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run', hookContext: 'ConversationLocationDataStore | undefined', slotInject: 'ChatNodeTurnDataInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-chat UserMessageNodeView key \'user\'', 'client-ui-chat UserMessageNodeView key \'steering\'', - 'client-ui-chat ContextMessageNodeView key \'context\'', - 'client-ui-chat SystemPromptNodeView key \'system-prompt\'', + 'client-ui-chat TurnTriggerNodeView key \'turn-trigger\'', 'client-ui-chat AssistantNodeView key \'assistant-step\'', 'client-ui-chat CommandNodeView key \'command\'', 'client-ui-chat ManualCompactionNodeView key \'manual-compaction\'', @@ -293,7 +292,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:201', }, { key: 'conversation.chat.turnTail', @@ -354,7 +353,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:226', }, { key: 'conversation.composer', @@ -1128,7 +1127,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:214', }, { key: 'conversation.plan-review.actions', diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index e2b23855a9..168027e5f0 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -10,6 +10,9 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" - button "Code Catch an Auto-denied inner call" @@ -36,6 +39,9 @@ - tab "Trajectory" - text: Inspect the protected operation, but do not run it unless authorized. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" [expanded] - text: Failed - button "Tool call Rejected by Auto review" [expanded] - text: "OUT Tool was not executed. Reason: raw reason" diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 90fbaa8f11..627b38e7e8 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -6,10 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{clock}}" - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Ran commands" [expanded] - 'button "Failed Bash Error: tool call aborted" [expanded]' - text: "IN { \"command\": \"node -e \\\"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted" - button "Inspect" diff --git a/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index cd3aa40ebf..da982dc045 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -1,29 +1,8 @@ -- button "系统提示词" - text: 不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。 {{clock}} - button "复制" -- button "7 次工具调用" [expanded] -- button "上下文注入 @deepseek-ai/dsh-system-prompt" -- button "思考 The user wants four things done directly. But I need to read intro.md before editing (fs-observation-policy requires reading existing file first). The user says don't look at the directory first, but reading intro.md is needed for the edit tool. Let me just do it." -- button "读取 intro.md": - - text: 读取 - - button "intro.md" -- button "Bash List files and show notes.txt" -- button "思考 Now do all four actions. Edit intro.md, write src/util.ts, write app.local, bash append." -- button "编辑 intro.md +1 -1": - - text: 编辑 - - button "intro.md" - - text: +1 -1 -- button "写入 src/util.ts +3 -0": - - text: 写入 - - button "src/util.ts" - - text: +3 -0 -- button "写入 app.local +1 -0": - - text: 写入 - - button "app.local" - - text: +1 -0 -- button "Bash Append done to notes.txt" -- 'button "思考 All four done. Present the files? The user asked to create files. I should call present for the deliverables. Files: intro.md, src/util.ts, app.local, notes.txt. All are outputs. Let me present them."' -- button "交付文件 已交付 intro.md, src/util.ts, app.local, notes.txt" +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] +- button "修改了文件,执行了命令,已读取文件等" - paragraph: 四件事全部完成: - list: - listitem: diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md index df658c2f8f..b2349c1cc8 100644 --- a/snapshots/web/deepseek-messages-chat/ui.expected.md +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -7,11 +7,10 @@ - tablist: - tab "对话" [selected] - tab "轨迹" -- button "系统提示词" - text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} - button "复制" -- button "已思考" -- button "思考" +- status: 已完成工作 +- button "用时 {{duration}}" [disabled] - paragraph: MESSAGES_WEB_READY - button "复制" - button "好的回答" diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 193f1e1cff..e55f6d0a0e 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to reply with a single word. Let me comply." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index c9e9bad335..e2bdfdb2f1 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 193f1e1cff..e55f6d0a0e 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to reply with a single word. Let me comply." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index c9e9bad335..e2bdfdb2f1 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 373474e2e9..b3a5886d4a 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -7,13 +7,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: poem.txt TXT 16B - button "reference-1.png, click to view original": - img "reference-1.png" - text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: UPLOAD_ROUND_OK - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index aaa7617775..70a33fa7b6 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -7,14 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to run a simple bash command and reply with \"DONE\"." -- button "Bash Echo the test string" -- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 517580bf06..fe445c8042 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 439bfe0fed..6e48ead027 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -12,22 +12,12 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" -- button "6 tool calls" [expanded] -- button "Context injection goal" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""' -- button "Bash List package directories deterministically" -- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" -- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."' -- button "Bash List packages directory contents" -- button "Think There are 4 packages:" -- button "Failed Bash Randomly pick one package directory" -- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."' -- button "Bash Randomly select one package with Python" -- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."' -- button "Bash List all files in session-reference package" -- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."' +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands and updated the plan" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -43,15 +33,12 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "System prompt" -- button "5 tool calls · 2 messages" [expanded] -- button "Context injection goal" -- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."' -- button "Bash Randomly select another package with Python" -- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."' -- button "Bash Re-roll excluding the package from turn 1" -- button "Bash List all files in token-meter package" -- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."' +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" - paragraph: - strong: Turn 2 / 2 - text: — 随机选中的包: @@ -67,10 +54,9 @@ - text: ,turn 2: - code: token-meter - text: )。这一轮到此结束。 -- button "View goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" +- button "Updated the plan" - paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。 -- button "Update goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的" -- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"' +- button "Updated the plan" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index de78d8224b..d1e5b8ef4f 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -12,8 +12,11 @@ - button "Jump to turn 2" - group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" -- button "6 tool calls" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" - paragraph: - strong: Turn 1 / 2 - text: — 随机选中的包: @@ -29,8 +32,11 @@ - button "Bad response" - button "Branch into a new conversation" - text: {{clock}} -- button "System prompt" -- button "5 tool calls · 2 messages" +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} +- status: Worked +- button "Took {{duration}}" - paragraph: 两个 turn 都完成了 ✅ - paragraph: - strong: 总结: diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index bb2569da3d..56af32b617 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to reply with a single word. Let me comply." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 1cef83a500..6a61fb6364 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: LIGHTHOUSE - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 38df9a295c..c6c5fb3fcc 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -7,11 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index fb25f9e5d1..c6c5fb3fcc 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index f9a7c1eb37..393acbc5ff 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Failed +- button "Failed" [disabled] - status: - text: This turn failedAPI key is invalid - code: AUTH diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 4d5591c3d5..d1d0dd02c4 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index d4a77f298a..5858483b8e 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Failed +- button "Failed" [disabled] [expanded] - group: - status: Retried model request (2/2) · {{duration}} - status: diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 72429a5f1d..fbe7ee4caf 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -7,14 +7,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Worked +- button "Took {{duration}}" [expanded] - group: - status: Retried model request (1/5) · {{duration}} -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- button "Analysis completed" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index e02699f7b7..3a7ed02e3e 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 077e94936c..e97bf88a05 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Add files or run commands" diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 4f94675057..caf671643c 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -10,26 +10,22 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" - tooltip "Copy" -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" - paragraph: I will read both files before answering. +- button "Read files" - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" [disabled] -- text: Available only on the last message of a completed turn {{clock}} -- button "Read a.txt": - - text: Read - - button "a.txt" -- button "Read b.txt": - - text: Read - - button "b.txt" -- button "System prompt" -- text: Now give the final answer. {{clock}} +- text: Available only on the last message of a completed turn {{clock}} Now give the final answer. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: DONE - button "Copy" - button "Good response" @@ -37,6 +33,8 @@ - button "Branch into a new conversation" - text: {{clock}} Keep this later input in the original conversation. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - paragraph: ORIGINAL ONLY - button "Copy" - button "Good response" diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 747da8bbea..597c0845c7 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -7,10 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the bash tool to run exactly: printf 'MINIMAL_BASH_CARD_OK\\n'. Then reply exactly MINIMAL_PRESET_REQUEST_OK and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran commands" [expanded] - button "Bash printf 'MINIMAL_BASH_CARD_OK\\n'" [expanded] - text: "IN { \"command\": \"printf 'MINIMAL_BASH_CARD_OK\\\\n'\" } OUT MINIMAL_BASH_CARD_OK [Command finished with exit code 0]" - button "Inspect" diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 742e32348d..d467e0ba89 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -6,20 +6,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call · 1 message" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."' +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . -- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"' -- button "System prompt" -- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."' +- button "Called tools" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 17cb2ec82d..c19f25ad53 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -6,12 +6,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: plan Plan mode on. Use /plan off to leave. -- button "System prompt" -- text: "Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call · 1 message" -- button "System prompt" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "Copy" diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 3a6f85583c..73bc2d6046 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -1,16 +1,10 @@ -- button "系统提示词" - text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}} - button "复制" -- button "2 次工具调用 · 1 条消息" [expanded] -- button "上下文注入 @deepseek-ai/dsh-system-prompt" -- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses." +- status: 已完成工作 +- button "用时 {{duration}}" [expanded] +- button "已完成分析" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 -- button "写入 von-neumann.svg +83 -0": - - text: 写入 - - button "von-neumann.svg" - - text: +83 -0 -- button "思考 The file is created. Let me present it as a deliverable." -- button "交付文件 已交付 von-neumann.svg" +- button "修改了文件并已调用工具" - paragraph: - text: 已创建 - code: diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 20ec6c8afe..1cb2822096 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -7,18 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants one run_code program that:" -- text: Failed -- 'button "Code Error: code run failed (exception): AFTER_PRESENT"' -- button "Present files Delivery failed missing.txt" -- button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files" -- button "Present files Delivered report.txt, 说明.txt" -- button "Think The program ran as intended:" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools, ran code, ran commands" - paragraph: - text: "The single program ran exactly as ordered:" - code: present diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 77daf41f67..0b05a02961 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -7,12 +7,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- 'button "Think The user wants me to write a single `run_code` program that:"' +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Ran code, ran commands, read files" [expanded] +- button "Think" - button "Code Run bash echo and catch missing file read" - button "Bash Echo CODE_ROUND_OK" [expanded] - text: Done workspace echo CODE_ROUND_OK @@ -21,7 +21,7 @@ - button "Inspect" - text: Failed - 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"' -- button "Think The program ran successfully. Let me now reply DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 58b3ed28b3..555ac2fa97 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -7,17 +7,17 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" [expanded] +- button "Think" - button "Ask question 1/1 answered" [expanded] - term: Which color do you prefer? - definition: Blue Include accessibility notes - button "Inspect" -- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 6e7738f321..83e7b6ddf2 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 97591ecba3..a28798e0a5 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -6,11 +6,12 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" [expanded] +- button "Think" - button "Ask question cancelled" [expanded] - paragraph: This question set was cancelled before answers were submitted. - list: diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index e34a92efa9..534e44a81a 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Add files or run commands" diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 1d790c53f4..c452c2c6d1 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index ac54c9cb25..c69feb1beb 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -9,11 +9,12 @@ - tab "Trajectory" - group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"' -- button "System prompt" -- button "Context injection goal" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial +- button "Continuing goal {{clock}}": + - text: Continuing goal + - time: {{clock}} - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - region "To-dos": - button "To-dos 1 completed · 1 in progress" - text: Ongoing Goal Keep the composer context panels aligned diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 4efa97d7c0..aec80f474c 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -7,11 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 099622b124..aec80f474c 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 7d3eb1c9c4..71cf62a314 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Queue item to remove diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 35cfdc5eb4..a07bd2b535 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Edited queue item diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 4e3aaa548f..39be729820 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -11,10 +11,10 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Stopped +- button "Stopped" [disabled] - paragraph: partial - text: Stopped - button "Copy" @@ -26,7 +26,8 @@ - img "queued.png" - text: Compare with this screenshot {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" @@ -34,7 +35,8 @@ - button "Branch into a new conversation" - text: {{clock}} Continue with the queued comparison {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index f74ae43b62..e0a7619779 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - img "Queued message image" diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 72449c0f85..072cc0e02d 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -9,18 +9,19 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" [expanded] +- button "Think" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,8 +29,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" -- text: permission preset read-only +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index ca827139a0..341cbfbdce 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -9,18 +9,19 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" [expanded] +- button "Think" - button "Read a.txt": - text: Read - button "a.txt" - button "Read b.txt": - text: Read - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,8 +29,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" -- text: permission preset read-only +- status: Worked +- button "Took {{duration}}" [disabled] - 'button "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." [expanded]' - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index cedbc667dd..2ef855f629 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -9,18 +9,11 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" [expanded] -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." -- button "Read a.txt": - - text: Read - - button "a.txt" -- button "Read b.txt": - - text: Read - - button "b.txt" -- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Read files" - paragraph: DONE - button "Copy" - button "Good response" @@ -28,7 +21,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 57b3f91a06..bd16bdaf82 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -9,10 +9,10 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "2 tool calls" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" @@ -20,7 +20,8 @@ - button "Branch into a new conversation" - text: {{clock}} - button "compact Compacted 5 history items (~{{tokens}} tokens)" -- button "Context injection AGENTS.md" +- status: Worked +- button "Took {{duration}}" [disabled] - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index d0d16efd83..4fe37ce5c2 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -6,17 +6,16 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Load the editing-cordis-compositions skill with the skill tool, then reply DONE. {{date}} {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Context injection skill-catalog" -- button "Think Load the requested skill." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] +- button "Think" - button "Skill editing-cordis-compositions" [expanded] - region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. For deployment overrides, read `${DSH_HOME:-$HOME/.dsh}/profiles//cordis.patch.yml`, `${DSH_HOME:-$HOME/.dsh}/cordis.patch.yml`, and any launch `--patch` files for the `agent-presets` row's `roots` and `includeUserRoot`. Obtain the active profile and extra patch paths from the user when they are not in the task context; do not guess a different profile. ## Authoring a preset Use shell and file tools to locate the installed `@deepseek-ai/dsh-agent-presets` package under the active profile's `node_modules` or the deployment installation. Its `presets//` directory contains each shipped preset. If those files are unavailable, ask the user to copy the preset through the Web preset picker and provide the copied directory; runtime API inspection does not execute Remote methods. Copy the complete source directory, including skills and assets, into a new `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` directory (or the explicitly configured writable root). Refuse an existing destination. Set `name` and `description` in `preset.yml` and remove the copied roster `order`. Never overwrite the installed source. Edit the copy's `agent.cordis.yml` with the normal file tools. Writes outside the workspace follow the active filesystem approval policy. For profile-wide capabilities, author a workspace bundle and install it with `plugin_manager`; load `cordis-plugin-development` for packaging guidance. ## Service isolation A preset row that publishes a service needs an `isolate` realm containing both the provider and all its consumers. A tool that only consumes a host service remains outside that realm. Copy the shipped preset's existing groups rather than introducing service instances into the process-global realm. ```yaml - id: delegation name: cordis:group group: true isolate: workflowEngine: true config: - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` ## Verify a preset Check the edited YAML and referenced local files with file tools. Ask the user to select the new preset in the Web picker and start a session; this authoring agent cannot invoke the preset Remote API or start a Web session through inspection. Once the user provides a running session or browser control, inspect the visible tools and any activation diagnostic. File validation alone does not verify imports, service dependencies, or isolation. Report which checks ran and leave activation unverified until that session check succeeds. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" -- button "Think The skill is loaded." +- button "Think" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 755176ac89..4e16e7e977 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -7,13 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- text: Running -- button "Ask question waiting" - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- button "Waiting for your action" - text: "Interjection: include the word BANANA in your final reply." - button "Copy" - region "Ready to continue?": diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index a1427d9404..19eb0180d0 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -7,12 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Ask question 1/1 answered" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Asked questions" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - paragraph: Great, let's move forward. BANANA! diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 04ad8d224d..b1a77c7fb7 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" -- button "1 tool call" +- status: Worked +- button "Took {{duration}}" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" - paragraph: Great, let's move forward. BANANA! diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index 86ebfc5c77..c04320b0b6 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -12,6 +12,8 @@ - tab "Trajectory" - text: Give one concrete event sourcing example. {{clock}} - button "Copy" +- status: Worked +- button "Took {{duration}}" [disabled] - status: - strong: This subagent is read-only for now - text: The parent session is offline; reopen it to continue sending messages. diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 588d7bc778..607e142b62 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -1,7 +1,7 @@ -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 5e7253e2fe..aa66a4ca0b 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -13,23 +13,21 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- button "Thought for a while" [expanded] -- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 62af856938..6e67c79557 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -13,20 +13,19 @@ - navigation "Turn navigation": - button "Jump to turn 1" - button "Jump to turn 2" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" - button "Bad response" - button "Branch into a new conversation" -- text: {{clock}} -- button "System prompt" -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy" -- button "Thought for a while" +- status: Worked +- button "Took {{duration}}" - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy" - button "Good response" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 55a2af25bc..c6e866c5e2 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -9,12 +9,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" - button "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- paragraph: partial - list: - listitem: - text: Keep working until I stop you again. diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 7b3c3e481e..87f52c4f3a 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 860191368f..9651ea828e 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -7,13 +7,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Analysis completed" - paragraph: Reading the workspace now. +- button "Ran commands" [expanded] - button "Bash Print alpha to stdout" - paragraph: DONE - button "Copy" diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index bb0ce72149..e595219fdc 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -7,16 +7,15 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" - tooltip "Copy" -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop." -- paragraph: Reading the workspace now. -- button "Bash Print alpha to stdout" -- paragraph: partial - status: Deep diving... +- button "Deep diving for {{duration}}" [disabled] [expanded] +- button "Analysis completed" +- paragraph: Reading the workspace now. +- button "Ran commands" +- paragraph: partial - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 4312288efa..b8c3eea1da 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -7,10 +7,13 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Stopped +- button "Stopped" [disabled] [expanded] +- button "Analysis completed" +- paragraph: Reading the workspace now. +- button "Ran commands" - paragraph: partial - text: Stopped - button "Copy" diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 64a4b1b0ef..f27eee5725 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -7,10 +7,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}} - button "Copy" -- button "1 tool call · 1 message" +- status: Worked +- button "Took {{duration}}" - paragraph: DONE - button "Copy" - button "Good response" diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 27d0cce32c..f081df843e 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -7,11 +7,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Searched the web" [expanded] - button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search" - paragraph: SEARCH_DONE - button "Copy" diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md index 40d39e64cf..12ffccb420 100644 --- a/snapshots/web/workflow-run/ui.expected.md +++ b/snapshots/web/workflow-run/ui.expected.md @@ -1,12 +1,12 @@ -- button "System prompt" - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy" -- button "1 tool call" [expanded] -- button "Context injection @deepseek-ai/dsh-system-prompt" -- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- status: Worked +- button "Took {{duration}}" [expanded] +- button "Called tools" [expanded] +- button "Think" - button "Run workflow snapshot-flow" - button "snapshot-flow 1 member Completed" -- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop." +- button "Think" - paragraph: WORKFLOW_DONE - button "Copy" - button "Good response" diff --git a/tsconfig.host.json b/tsconfig.host.json index 1b0f829702..d0e7c20724 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -130,6 +130,7 @@ "apps/web/tests/sidebar-terminal.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", + "apps/web/tests/step-process.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/composer-tab-geometry.e2e.ts",