Merge pull request #4578 from deepseek-harness/revert-4565-ihsiang/work-details-toggle

Revert "feat(web): add work details presentation modes"
This commit is contained in:
imccyu authored and GitHub committed 2026-09-18 22:12:19 +08:00
commit 94607e48d6
232 files changed
+2844 -3789

No files matched your search

@@ -1,6 +0,0 @@
# 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
@@ -1,41 +0,0 @@
# 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.
@@ -1,41 +0,0 @@
# 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 命令行。过滤仅影响展示,持久化记录仍可在轨迹中查看。唤起轮次的上下文被分类为独立轮次发起提示并保持可见,用于解释新回复的触发原因。只有元数据时不生成空过程折叠。
@@ -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: 38c002977f0368cf046ddb2121e5cddb3e36da8f
2026-09-17-developer-tools-settings.zh.md: e2b996950664ff57464c877c719469bbd66ca887
2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2
2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c
@@ -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 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.
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.
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 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.
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.
@@ -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 执行尚未验证。
-4
View File
@@ -43,10 +43,6 @@ 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)
@@ -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(0)
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1)
expect(await page.locator(
'[data-chat-flow-kind="system-prompt"]',
'[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
).count()).toBe(0)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
expect(sessionEvents.flatMap(event => (
@@ -691,7 +691,6 @@ 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)
+10 -33
View File
@@ -4,7 +4,6 @@
// without depending on CSS-module names or virtualizer DOM positions.
import {
ToolCallId,
type MessageSource,
createAssistantMessage,
createSystemMessage,
createToolResultMessage,
@@ -26,10 +25,6 @@ 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. */
@@ -201,30 +196,24 @@ 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', input, { surfaceOp: 'append' })
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' })
if (turn === 1) {
session.append('session/title', {
title: options.title,
messageSeqs: options.wakingInput === undefined ? [user.seq] : [],
source: { kind: options.wakingInput === undefined ? 'fallback' : 'user' },
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
}
@@ -254,19 +243,7 @@ export function createChatScrollFixture(options: ChatScrollFixtureOptions): Chat
)
session.append('step/end', { turn, step: 1 })
}
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' } })
}
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return { log: fixtureLog(session), markers, title: options.title, turns }
@@ -377,10 +377,7 @@ 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.locator('[data-turn-process]').click()
for (const toggle of await page.locator('[data-step-process] > button[aria-expanded="false"]').all()) {
await toggle.click()
}
await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click()
for (const row of [
/^Search clickable link styles/,
/^Fetch /,
@@ -1,4 +1,4 @@
/** An explicit disabled setting overrides the Host default and survive browser reload through the ordinary settings UI. */
/** Shared settings are off on a fresh Host 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')).toContain('ui-developer-tools:\n enabled: false')
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:')
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')
-2
View File
@@ -1,5 +1,4 @@
/** 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'
@@ -52,7 +51,6 @@ 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()
@@ -8,9 +8,7 @@
- tab "Trajectory"
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
- button "Copy"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Edited files, called tools, searched code, etc." [expanded]
- button "16 tool calls" [expanded]
- button "Write site/report.html +1 -0":
- text: Write
- button "site/report.html"
@@ -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"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Called tools" [expanded]
- button "Think"
- 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."
- button "Inspect plugins 0 dynamic plugins"
- button "Think"
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
- tablist "Plugin source":
- tab "Client"
@@ -28,22 +28,18 @@
- 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"
- button "Think The Host returned snap-1/pkg-1, so I will request its first activation."
- text: Run Cordis Plugin snap-1 · pkg-1 Ready
- button "Inspect"
- text: snap-1/pkg-1 is awaiting user approval (run-1).
- button "Think"
- button "Think The activation request has been submitted, so I will return the requested readiness marker."
- paragraph: CORDIS_UI_READY
- button "Copy"
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- text: 9/1 {{clock}}
- button "Plugin status updated 9/1 {{clock}}":
- text: Plugin status updated
- time: 9/1 {{clock}}
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Thought for a while"
- paragraph: The Cordis Plugin is running.
- button "Copy"
- button "Good response"
@@ -51,9 +47,7 @@
- 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"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Called tools" [expanded]
- button "1 tool call" [expanded]
- text: Stop Cordis Plugin snap-1
- button "Inspect"
- text: Dynamic Plugin snap-1 is stopped; its definition and versions remain.
@@ -15,11 +15,10 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
- status: Worked
- button "Took {{duration}}" [disabled]
- 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"
- paragraph: "Review complete: no actionable findings."
- button "Copy"
- button "Good response"
@@ -15,11 +15,8 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
- status: Worked
- button "Took {{duration}}" [disabled]
- button "System prompt"
- button "Thought for a while"
- paragraph: "Review complete: no actionable findings."
- button "Copy"
- button "Good response"
@@ -8,8 +8,6 @@
- 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: 注意:
@@ -8,8 +8,6 @@
- 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"
@@ -7,8 +7,6 @@
- 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:"
@@ -8,8 +8,6 @@
- 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
@@ -8,8 +8,7 @@
- tab "Trajectory"
- text: Research notes what changed? Referenced session · Research notes {{clock}}
- button "Copy"
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Session recall Research notes"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -19,7 +19,7 @@
- text: Font size Only affects conversation content 14
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Controls how turns and steps expand by default
- text: px Conversation display Controls process content in completed turns
- button "Compact"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
@@ -19,8 +19,8 @@
- text: 字号大小 仅影响会话内容的字号 14
- button "增大字号"
- button "减小字号"
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
- button "简洁"
- text: px 对话显示 控制已完成轮次的过程内容
- button "紧凑"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
@@ -7,12 +7,14 @@
- 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"
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Thought for a while" [expanded]
- button "Context injection @deepseek-ai/dsh-system-prompt"
- button "Context injection user-invoke-demo"
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy"
- button "Good response"
@@ -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"
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Thought for a while"
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy"
- button "Good response"
@@ -37,8 +37,6 @@
- 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"
@@ -46,8 +44,6 @@
- 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"
@@ -55,8 +51,6 @@
- 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"
@@ -64,8 +58,6 @@
- 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"
@@ -73,8 +65,6 @@
- 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"
@@ -82,8 +72,6 @@
- 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"
@@ -91,8 +79,6 @@
- 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"
@@ -100,8 +86,6 @@
- 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"
@@ -109,8 +93,6 @@
- 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"
@@ -118,8 +100,6 @@
- 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"
@@ -127,8 +107,6 @@
- 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"
@@ -136,8 +114,6 @@
- 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"
@@ -145,8 +121,6 @@
- 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"
@@ -154,8 +128,6 @@
- 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"
@@ -163,8 +135,6 @@
- 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"
@@ -172,8 +142,6 @@
- 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"
@@ -181,8 +149,6 @@
- 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"
@@ -190,8 +156,6 @@
- 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"
@@ -199,8 +163,6 @@
- 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"
@@ -208,8 +170,6 @@
- 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"
@@ -217,8 +177,6 @@
- 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"
@@ -226,8 +184,6 @@
- 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"
@@ -235,8 +191,6 @@
- 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"
@@ -244,8 +198,6 @@
- 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"
@@ -253,8 +205,6 @@
- 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"
@@ -262,8 +212,6 @@
- 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"
@@ -271,8 +219,6 @@
- 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"
@@ -280,8 +226,6 @@
- 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"
@@ -7,27 +7,18 @@
- 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"
- 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]
- 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"
@@ -7,11 +7,13 @@
- 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"
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Asked questions"
- 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"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
@@ -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"
- status: Worked
- button "Took {{duration}}"
- button "1 tool call"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
@@ -1 +0,0 @@
- button "Ran commands"
@@ -1 +0,0 @@
- button "Took 1s" [disabled]
@@ -1,5 +0,0 @@
- 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
@@ -1 +0,0 @@
- button "Think"
@@ -1,3 +0,0 @@
- button "Background task updated HH:mm":
- text: Background task updated
- time: HH:mm
@@ -1,5 +0,0 @@
- 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."
@@ -1 +0,0 @@
- button "Took 1s"
@@ -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(0)
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2)
expect(await page.locator(
'[data-chat-flow-kind="system-prompt"]',
'[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
).count()).toBe(0)
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
+11 -4
View File
@@ -328,11 +328,18 @@ 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')
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)
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)
}
observedReasoning.resolve(undefined)
return await settled
+4 -11
View File
@@ -156,12 +156,10 @@ 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)
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 expect.poll(
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 30_000 })
@@ -192,8 +190,6 @@ 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.
@@ -299,9 +295,6 @@ 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)
+1 -1
View File
@@ -227,7 +227,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(
() => page.getByRole('button', { name: 'System prompt', exact: true }).count(),
{ timeout: 10_000 },
).toBe(0)
).toBe(2)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). Branch renders only under assistant answers — user
+1 -3
View File
@@ -19,7 +19,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
import { 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,8 +154,6 @@ 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()
-3
View File
@@ -1,5 +1,4 @@
/** 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'
@@ -182,8 +181,6 @@ 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()
-4
View File
@@ -16,7 +16,6 @@
* 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'
@@ -419,9 +418,6 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
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()
-2
View File
@@ -128,8 +128,6 @@ 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()
-2
View File
@@ -9,7 +9,6 @@
// 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'
@@ -82,7 +81,6 @@ 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()
-3
View File
@@ -7,7 +7,6 @@
// 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'
@@ -300,7 +299,6 @@ 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 })
@@ -391,7 +389,6 @@ 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()
+3 -4
View File
@@ -390,7 +390,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
expect(tripwire.warnings).toEqual([])
})
it('renders the direct message without an infrastructure recall row', async () => {
it('renders the durable direct-message then recall order', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
await group.waitFor({ timeout: 15_000 })
@@ -402,13 +402,12 @@ 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.getByText('what changed?', { exact: false }).first().waitFor({ timeout: 15_000 })
expect(await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).count()).toBe(0)
await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(TARGET_SESSION_ID).join('{{targetId}}')
await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Research notes what changed?')
expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes'))
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md'])
+19 -7
View File
@@ -22,7 +22,7 @@ import {
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
} from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
@@ -202,10 +202,24 @@ describe('web e2e: fresh round trip through the real assembly', () => {
await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
})
it.skipIf(MODE === 'record')('keeps system prompt infrastructure out of expanded Chat', async () => {
it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
await expandTurnProcesses(page)
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)
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)
})
it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => {
@@ -215,9 +229,7 @@ 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 reasoning = page.locator('[data-variant="think"]').first()
await expandOwningTurnProcess(page, reasoning)
const think = reasoning.getByRole('button').first()
const think = page.getByRole('button', { name: /^Think/ }).first()
expect(await think.getAttribute('aria-expanded')).toBe('false')
await think.click()
await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
+4 -5
View File
@@ -302,7 +302,7 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/** Explicit developer-tool preference; omitted keeps the saved value or product default. */
/** The scaffold enables developer tools unless false preserves the shipped default. */
developerTools?: boolean
/** Profile resolver backend used by this test Host; defaults to runtime coverage. */
profileResolutionMode?: Extract<ProfileResolutionMode, 'dual' | 'runtime'>
@@ -824,8 +824,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
await ctx.loader.await()
await auditStartupEntries(ctx, 'web e2e scaffold')
if (options.developerTools !== undefined) {
await ctx.settings.update('ui-developer-tools', { enabled: options.developerTools })
if (options.developerTools !== false) {
await ctx.settings.update('ui-developer-tools', { enabled: true })
}
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
@@ -1621,8 +1621,7 @@ export async function captureExpandedTurnProcessAria(
const opened: number[] = []
for (let index = 0; index < count; index++) {
const control = controls.nth(index)
if (!await control.isVisible() || await control.isDisabled()
|| await control.getAttribute('aria-expanded') === 'true') continue
if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
await control.click()
opened.push(index)
}
+94 -19
View File
@@ -353,7 +353,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
}],
},
}), { surfaceOp: 'append' })
expect(await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true }).count()).toBe(0)
// 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 })
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
@@ -380,16 +383,8 @@ 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.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
})
const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize)
await toggle.click()
try {
await thinking.locator('p').waitFor({ timeout: 10_000 })
@@ -416,9 +411,60 @@ describe('web e2e: seeded history renders through cold resume', () => {
}
})
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')('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('<system-reminder>')
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')('restores the active turn rail mark across Chat and Trajectory', async () => {
@@ -666,17 +712,24 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (bodyError !== undefined) throw bodyError
})
it.skipIf(MODE === 'record')('persists an Access-chip switch without a permission row in Chat', async () => {
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 <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset read-only`,
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
const access = page.getByRole('button', { name: 'Access mode, current: Read Only' })
await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true)
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)
// Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
expect(await row.count()).toBe(0)
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)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
@@ -723,6 +776,28 @@ 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()
+9 -9
View File
@@ -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: expanded/)
.toMatch(/ui-chat:\n\s+transcriptView: normal/)
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')
+1 -1
View File
@@ -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.locator('[data-sidebar-terminal]').getByRole('status').ariaSnapshot(), webSnapshotMode())
await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
await reconnect.click()
transport.reconnect()
+25 -16
View File
@@ -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 and reply; injected instructions remain in the Session log.
// the gesture bubble, the collapsed context-injection row, and the reply.
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
@@ -11,7 +11,6 @@ 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,
@@ -23,7 +22,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
@@ -100,7 +99,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, logged instructions, and a replayed answer', async () => {
it('claims /name args into a gesture bubble, an injection row, 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 })
@@ -123,21 +122,31 @@ 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 })
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(`<skill_content name="${SKILL_NAME}">`))
expect(instructions).toContain('Reply with the fixture acknowledgement line.')
expect(instructions).not.toContain(ARGS_TEXT)
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).
expect(await bubble.count()).toBe(1)
expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
expect(await page.locator('[data-chat-flow-kind="context"]').count()).toBe(0)
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: `<skill_content name="${SKILL_NAME}">` })
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()
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -7,13 +7,14 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- button "Context injection @deepseek-ai/dsh-system-prompt"
- 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
+1 -1
View File
@@ -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({ state: 'attached', timeout: 10_000 })
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 })
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)
-319
View File
@@ -1,319 +0,0 @@
/** 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<string, unknown>
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<void> => {
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<string, unknown>, 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() }
})
+6 -11
View File
@@ -64,26 +64,21 @@ export async function expandTurnProcesses(page: Page): Promise<void> {
const count = await controls.count()
for (let index = 0; index < count; index++) {
const control = controls.nth(index)
if (await control.isEnabled() && await control.getAttribute('aria-expanded') !== 'true') await control.click()
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
}
}
/**
* Expand the Turn and secondary process groups containing a possibly hidden descendant.
* Expand the Turn-process group containing one possibly hidden descendant.
* @param page - page containing the Chat view.
* @param target - DOM locator matching the descendant even while its process groups are hidden.
* @param target - descendant whose owning Turn process should open.
*/
export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
await target.waitFor({ state: 'attached', timeout: 10_000 })
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
if (turn === undefined) return
if (turn === undefined || await target.isVisible()) return
const control = page.locator(`[data-turn-process="${turn}"]`)
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()
await control.waitFor({ state: 'visible', timeout: 10_000 })
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
}
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
+3 -5
View File
@@ -16,7 +16,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
import { 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,7 +112,6 @@ 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)
@@ -139,7 +138,6 @@ 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) => {
@@ -228,13 +226,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-step-process-body]').evaluate((host) => {
await page.locator('[data-conversation-scroll]').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-step-process-body]')
const host = button.closest('[data-conversation-scroll]')
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()
-1
View File
@@ -69,7 +69,6 @@ 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)
+12 -21
View File
@@ -135,10 +135,11 @@ 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)
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)
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)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 10_000 })
@@ -231,7 +232,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, Detailed, and Expanded', async () => {
it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
const { settled } = await sendPrompt()
@@ -244,30 +245,22 @@ 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' })
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 dialog.getByRole('button', { name: 'Compact', exact: true }).click()
await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
await page.keyboard.press('Escape')
await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
await process.click()
await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0)
await tool.waitFor({ state: 'visible', timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
.toMatch(/ui-chat:\n\s+transcriptView: expanded/)
.toMatch(/ui-chat:\n\s+transcriptView: normal/)
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const restored = page.getByRole('dialog', { name: 'Settings' })
await restored.getByRole('button', { name: 'Expanded', exact: true }).click()
await restored.getByRole('button', { name: 'Normal', exact: true }).click()
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
await page.keyboard.press('Escape')
await process.waitFor({ timeout: 10_000 })
expect(await process.getAttribute('aria-expanded')).toBe('true')
expect(await process.getAttribute('aria-expanded')).toBe('false')
expect(await tool.isVisible()).toBe(false)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
@@ -277,8 +270,6 @@ 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()
+1 -4
View File
@@ -15,7 +15,7 @@ import {
type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, expandOwningTurnProcess, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
} from './support.ts'
const MODE = webSnapshotMode()
@@ -90,7 +90,6 @@ 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 })
@@ -179,7 +178,6 @@ 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)
@@ -206,7 +204,6 @@ 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')
-1
View File
@@ -139,7 +139,6 @@
"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",
@@ -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, nextTurnInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
import { nextStepInboxDefinition } 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,7 +45,6 @@ export interface ConversationFoldWorkerReport {
class BenchEventDefinitions {
readonly definitions: readonly ConversationNodeDefinition[] = [
nextStepInboxDefinition,
nextTurnInboxDefinition,
messageDefinition,
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: e892d95b8a5e53bc71304d012ee81eb4b606ef1d
README.zh.md: 6704a6e54738633f27062a0f7ec65c83bdb81d05
README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc
README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d
+6 -10
View File
@@ -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 visibility](#system-prompt-row)
- [System prompt row](#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.
<a id="system-prompt-row"></a>
## System prompt visibility
## System prompt row
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.
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.
<a id="command-and-failure-rows"></a>
## Command and failure rows
@@ -58,18 +58,16 @@ On non-loopback browsers, the preference remains process-local because the setti
<a id="completed-turn-footer"></a>
## Completed-turn footer
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 completed-turn action footer starts 20px below the preceding prose or extension content.
-----
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.
<a id="turn-process-folding"></a>
## 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. 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.
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.
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 processLine truncated
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.
-----
@@ -119,5 +117,3 @@ None.
</details>
**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.
+6 -10
View File
@@ -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 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
<a id="system-prompt-row"></a>
## 系统提示词可见性
## 系统提示词行
所有工作过程展示模式均不显示系统提示词行。其持久化记录仍可在轨迹中查看;Chat 过滤不改变模型输入。
每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。
<a id="command-and-failure-rows"></a>
## 指令与失败行
@@ -58,18 +58,16 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
<a id="completed-turn-footer"></a>
## 已完成轮次的页脚
已完成轮次的操作页脚固定在本轮底部,距前方正文或扩展内容 20px。仅最后一轮以回复正文结尾时常显按钮;末尾为推理、工具、提示或其他内容时,与历史轮次一样通过悬停或键盘聚焦显示。不支持悬停的设备始终显示按钮。反馈与复制仍针对最后一条文字回复。
已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。
-----
Chat 快照构建器维护稳定的步骤分组布局、缓存的活动摘要和轮次页脚展示条件。仅正文变化的流式更新只刷新受影响的分组,不扫描历史分组;渲染位置变化和分页会重建布局,同时保留未变化分组的引用。未知的轮次状态不会把末尾分组标记为完成。整轮生命周期变化通过礼貌播报区域通知读屏器,时钟跳动不会重复播报。
<a id="turn-process-folding"></a>
## 轮次过程折叠
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式生成期间,收起预览仅在某段第一行结束后切换;尚未结束的第一行不会替换当前预览。预览从左侧起始,超出可用宽度的内容在右缘渐隐。结算后,简洁模式只显示「思考」,详细和完全展开模式显示首行预览。
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。折叠摘要保持单行,在流式输出期间跟随最新思考行,结算后显示第一行。
「设置 → 通用设置」提供持久化到 `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` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未Line truncated
「设置 → 通用设置」提供持久化到 `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 默认收起。
-----
@@ -119,5 +117,3 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有
</details>
**运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。
从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。所有工作过程展示模式均隐藏普通 pre-step 注入,其持久化记录仍可在轨迹中查看。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 通知的标题和图标仅使用持久化的来源类别字段;UI 不从生产方正文推断结果,展开后的原始内容仍是结果信息的依据。任务消息使用纸飞机图标,其他已识别来源使用对应的 primitive 图标,未知来源使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
+6 -6
View File
@@ -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 work details presentation mode. */
/** Field carrying the completed-Turn transcript presentation mode. */
export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
/** Transcript presentation modes accepted at settings boundaries. */
export const TRANSCRIPT_VIEW_MODES = ['compact', 'detailed', 'expanded'] as const
export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const
/** Work details presentation. */
/** Completed-Turn transcript 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 {
/** Work details preference; normal is accepted only for existing saved settings. */
transcriptView: TranscriptViewMode | 'normal'
/** Presentation mode for completed Turn process content. */
transcriptView: TranscriptViewMode
/** 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<ChatSettings> = z.object({
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES, 'normal']).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
})
@@ -23,9 +23,6 @@ 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. */
@@ -44,7 +41,7 @@ export interface AssistantMarkdownProps {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
compactTranscript = true, blocks, streaming, interrupted, renderMessageImages,
blocks, streaming, interrupted, renderMessageImages,
reasoningHidden = false, revealProcess, mentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
@@ -86,7 +83,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
hidden={reasoningHidden}
reveal={revealProcess}
>
<ReasoningRow compact={compactTranscript} text={block.text} running={streaming && i === last} t={t} />
<ReasoningRow text={block.text} running={streaming && i === last} t={t} />
</ProcessReasoning>,
)
break
@@ -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({
compactTranscript = true, node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
}: ChatNodeViewProps<'assistant-step'>) {
const data = node.data
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@@ -28,7 +28,6 @@ export const AssistantNodeView = memo(function AssistantNodeView({
const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
return (
<AssistantMarkdown
compactTranscript={compactTranscript}
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
@@ -1,15 +1,14 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { storedTurnProcessEntry } from '../stores.ts'
import { useSearchableHidden } from './searchable-hidden.ts'
import css from './ChatView.module.css'
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly assistantPart?: 'reasoning' | 'response'
readonly nodeKey: string
readonly useChatNode: ChatViewSlotProps['useChatNode']
readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess']
@@ -37,18 +36,11 @@ function turnOf(node: ChatNode | undefined): number | undefined {
/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, assistantPart, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
}: ChatNodeSeatProps) {
const sourceNode = useChatNode(nodeKey)
const node = useMemo(() => {
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 node = useChatNode(nodeKey)
const routedNode = node as ChatNode | undefined
const turn = turnOf(routedNode)
const processPresentation = useChatNodeProcess(nodeKey)
@@ -57,44 +49,45 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
? undefined
: storedTurnProcessEntry(state, processSpec.turn))
const processEntry = processSpec !== undefined
&& storedEntry?.answerStep === (processSpec.answerStep ?? 0)
&& processSpec.answerStep !== null
&& storedEntry?.answerStep === processSpec.answerStep
? storedEntry
: undefined
const liveProcess = processPresentation !== undefined && !processPresentation.turnClosed
const alwaysOpen = liveProcess || turnProcessAlwaysOpen(routedNode)
const processOpen = alwaysOpen || processEntry !== undefined
const processOpen = processEntry !== undefined
const setOpen = useCallback((open: boolean) => {
if (processSpec !== undefined && !alwaysOpen) {
actions.setTurnProcessOpen(processSpec.turn, (processSpec.answerStep ?? 0), open)
if (processSpec !== undefined && processSpec.answerStep !== null) {
actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open)
}
}, [actions, processSpec, alwaysOpen])
}, [actions, processSpec])
const processWindowReady = processSpec !== undefined
&& processPresentation !== undefined
&& compactTranscript
&& processSpec.answerAnchorSeq !== null
&& processPresentation.turn === processSpec.turn
&& (!historyIncomplete || processPresentation.turnStarted)
&& processPresentation.turnClosed
&& !historyIncomplete
const processMember = routedNode !== undefined
&& processWindowReady
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
&& routedNode.anchorSeq >= processSpec.processStartSeq
&& (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq)
&& 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
&& (liveProcess || processMember || ownsDisclosure)
&& (processMember || (ownsDisclosure
&& (processPresentation.hasExternalProcess || processSpec.inlineReasoning)))
const turnProcess = useMemo(() => processSpec === undefined
? undefined
: {
spec: processSpec,
foldable,
hasContent: processPresentation?.hasExternalProcess === true || processSpec.inlineReasoning,
open: processOpen,
setOpen,
}, [
foldable, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen,
foldable, processOpen, processSpec, setOpen,
])
const controllerInactive = routedNode?.kind === 'turn-process'
&& !foldable
@@ -107,12 +100,6 @@ 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<ChatNodeOwnerProps | null>(() => node === undefined
? null
: {
@@ -125,10 +112,9 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
renderMessageImages,
fileMentions,
turnProcess,
compactTranscript,
}, [
node, cwd, openFile, openSkill, inspectCall, forkAt,
loadImage, renderMessageImages, fileMentions, turnProcess, compactTranscript,
loadImage, renderMessageImages, fileMentions, turnProcess,
])
if (routedNode === undefined || owner === null) return null
const turnData = turnDataOf(routedNode)
@@ -140,7 +126,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
<div
ref={wrapperRef}
className={css.flowItem}
data-chat-anchor-key={assistantPart === 'reasoning' ? `${routedNode.key}:reasoning` : routedNode.key}
data-chat-anchor-key={routedNode.key}
data-chat-flow-key={routedNode.key}
data-chat-flow-kind={routedNode.kind}
data-chat-turn={turn}
@@ -148,19 +134,17 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
data-turn-process-hidden={processHidden || undefined}
data-turn-process-answer={compactAnswer || undefined}
>
<Fragment key={disclosureReset}>
{renderSlot('conversation.chat.node', routedOwner, {
entryKey: routedNode.kind,
hookContext: turnData,
fallback: (
<JsonBlock
label={t('message.unknownSurface', { type: routedNode.kind })}
payload={routedNode.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
),
})}
</Fragment>
{renderSlot('conversation.chat.node', routedOwner, {
entryKey: routedNode.kind,
hookContext: turnData,
fallback: (
<JsonBlock
label={t('message.unknownSurface', { type: routedNode.kind })}
payload={routedNode.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
),
})}
</div>
)
})
@@ -46,31 +46,11 @@
/* `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. */
:is(.column, .processBody) > :not([hidden]):not(.flowItem:empty)
.column > :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 {
@@ -97,6 +77,65 @@
/* 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);
@@ -1,8 +1,10 @@
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
// otherwise this view owns it. Each row subscribes to one stable node key.
import { useCallback, useMemo, useRef, useState } from 'react'
import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
import type {
ConversationTimelineSnapshot, RenderMessageImages,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import {
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
@@ -10,10 +12,11 @@ import {
import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeList } from './StepProcessList.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.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. */
@@ -49,6 +52,60 @@ 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 (
<div className={css.turnStatus} role="status" aria-live="polite">
{t('chat.deepDiving')}
{showClock && (
<span className={css.turnStatusClock} aria-hidden>
{formatRunDuration(elapsedMs, t)}
</span>
)}
</div>
)
}
type ChatNodeListProps = Omit<ComponentProps<typeof ChatNodeSeat>, 'nodeKey'> & {
readonly order: readonly string[]
}
const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) {
return order.map(nodeKey => (
<ChatNodeSeat key={nodeKey} nodeKey={nodeKey} {...seatProps} />
))
})
/**
* The chat view slot entry: pure component over the composed props; each
* ordered business Node crosses the keyed renderer seat.
@@ -71,6 +128,7 @@ 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)
@@ -79,8 +137,7 @@ export function ChatView({
const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const transcriptMode = useTranscriptView(mode => mode)
const compactTranscript = transcriptMode === 'compact'
const compactTranscript = useTranscriptView(mode => mode === 'compact')
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
const [fileOpenBusy, setFileOpenBusy] = useState(false)
// Close/retry must ignore a settlement that started before the latest
@@ -135,6 +192,7 @@ 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
@@ -176,12 +234,11 @@ export function ChatView({
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<ChatNodeList
useChat={useChat}
order={order}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
historyIncomplete={hasMore}
compactTranscript={compactTranscript}
expandedSteps={transcriptMode === 'expanded'}
useStore={useStore}
actions={actions}
cwd={cwd}
@@ -199,6 +256,9 @@ export function ChatView({
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
{pendingSteering.map(item => (
<PendingSteeringBubble
key={item.id}
@@ -1,4 +1,5 @@
/* Model-facing content within a Turn trigger notice. */
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
@@ -7,3 +8,154 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Source fields beneath the text: dimmer than the content they describe. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: a replacement notice above one row per published entry. */
.catalogNotice {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
}
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}
@@ -1,19 +1,44 @@
/** Original model-facing content retained by non-human Turn notices. */
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ContextMessageNode } from '../contract/snapshot.ts'
import type { KnownContextForm } from '@deepseek-ai/dsh-client-ui-conversation/client'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
/** Rows a list body materializes before summarizing the remainder. */
const MAX_ENTRIES = 200
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** One run of the model-facing content: adjacent text, or one unknown block. */
type ContentRun = { text: string } | { block: unknown }
/** Join adjacent text while preserving unknown blocks in their original order. */
/**
* 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.
*/
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
const runs: ContentRun[] = []
for (const block of content) {
@@ -28,6 +53,11 @@ 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
@@ -36,11 +66,77 @@ function boundedText(text: string, t: Translate): string {
}
/**
* 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.
* 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.
*/
export function ContextContentBody({ content, t }: {
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 (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
</div>
))}
</dl>
)
}
/**
* 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) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => 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 }: {
content: ContextMessageNode['content']
t: Translate
}): ReactNode {
@@ -61,3 +157,436 @@ export function ContextContentBody({ 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 (
<>
<ModelFacingContent content={content} t={t} />
<SourceFields source={source} formRendered={false} t={t} />
</>
)
}
/** 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<string>()
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 `<system-reminder>` 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 <OpaqueBody content={content} source={source} t={t} />
const baseline = asRecord(source)?.['baseline'] === true
return (
<>
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(instructionAction(change.action, baseline))}
</span>
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** 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 <OpaqueBody content={content} source={source} t={t} />
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 && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
<ul className={css.entries} data-context-entries>
{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.
<li key={index} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
{shown.length < entries.length && (
<p className={css.catalogNotice} data-context-entries-truncated>
{t('message.context.catalog.more', { count: entries.length - shown.length })}
</p>
)}
{/* The block union is merge-extensible: a catalog message carrying an
unknown block still shows it rather than dropping model-visible content. */}
<UnknownBlocks blocks={rest} t={t} />
</>
)
}
/** 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
* `<system-reminder>` 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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
/**
* `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 <ModelFacingContent content={content} t={t} />
}
/**
* `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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** 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 <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** 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: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
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)}`)
}
}
}
@@ -0,0 +1,64 @@
/* 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);
}
@@ -0,0 +1,71 @@
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 (
<DisclosureRow
className={css.root}
icon={producer.role === 'recall'
? <span data-context-recall-icon><ReferenceIconRegular kind="session" /></span>
: <IconContextInjectionOutlineRegular size={14} />}
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. */
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{producer.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
</div>
</DisclosureRow>
)
}
@@ -3,6 +3,32 @@
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;
}
@@ -13,7 +39,6 @@
.title {
font-weight: 400;
transition: color 100ms ease;
}
.separator {
@@ -34,12 +59,6 @@
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],
@@ -59,3 +78,9 @@
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -1,8 +1,6 @@
import { useState } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import {
DisclosureRow, IconApiOutlineRegular, TextShimmer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import a11yCss from './accessibility.module.css'
import css from './GenericCommandCard.module.css'
@@ -31,7 +29,6 @@ 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 (
@@ -44,7 +41,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
titleClassName={css.title}
chevronClassName={css.chevron}
icon={<IconApiOutlineRegular size={14} />}
title={running ? <TextShimmer>{title}</TextShimmer> : title}
title={title}
open={open}
expandable={body !== null}
expandOnRowClick
@@ -53,9 +50,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>
{running ? <TextShimmer>{summary}</TextShimmer> : summary}
</span>
<span className={css.summary} data-error={state === 'error' || undefined}>{summary}</span>
</>
)}
>
@@ -6,6 +6,7 @@ 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'
@@ -336,6 +337,20 @@ 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 (
<ContextInjectionRow
content={data.content}
source={data.source}
producer={data.producer}
form={data.form}
t={t}
/>
)
})
/** Automatic compaction keyed Chat renderer. */
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
return <CompactionItem node={node.data} t={t} />
@@ -83,12 +83,17 @@
text-overflow: ellipsis;
}
.summary[data-streaming] {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
.summary[data-follow-end] {
display: flex;
justify-content: flex-end;
}
.summary[data-streaming] .summaryText {
.summary[data-follow-end] .summaryText {
flex: 0 0 auto;
width: max-content;
min-width: 100%;
overflow: visible;
text-align: start;
text-overflow: clip;
}
@@ -11,41 +11,25 @@ function firstLine(text: string): string {
return newline === -1 ? text : text.slice(0, newline)
}
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
}
function latestLine(text: string): string {
const visible = text.trimEnd()
const newline = visible.lastIndexOf('\n')
return newline === -1 ? visible : visible.slice(newline + 1)
}
/**
* 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.
* 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.
* @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, compact, t }: { compact: boolean; text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
const [expanded, setExpanded] = useState(false)
const labels = useMemo(() => markdownLabels(t), [t])
const summary = (running ? latestCompletedParagraphFirstLine(text) : firstLine(text)).replaceAll('**', '')
const showSummary = (running || !compact) && summary !== ''
const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
return (
<div
@@ -66,14 +50,14 @@ export function ReasoningRow({ text, running, compact, t }: { compact: boolean;
expandable
expandOnRowClick
onToggle={() => { setExpanded(value => !value) }}
collapsedContent={showSummary ? (
collapsedContent={(
<>
<span className={css.separator} aria-hidden />
<span className={css.summary} data-streaming={running || undefined}>
<span className={css.summary} data-follow-end={running || undefined}>
<span className={css.summaryText}>{summary}</span>
</span>
</>
) : undefined}
)}
>
<div className={css.thinkBody}>
<MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
@@ -1,113 +0,0 @@
.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;
}
}
@@ -1,199 +0,0 @@
/** 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<ComponentProps<typeof ChatNodeSeat>, '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<ProcessTitleActivity, ReactNode> = {
thinking: <IconThinkOutlineRegular />,
read: <IconBrowseOutlineRegular size={14} />,
search: <IconSearchOutlineRegular size={14} />,
edit: <IconEditOutlineRegular size={14} />,
commands: <IconApiOutlineRegular />,
code: <IconCodeOutlineRegular size={14} />,
webSearch: <IconGlobeOutlineRegular />,
webFetch: <IconBrowseOutlineRegular size={14} />,
subagents: <IconAgentPresetOutlineRegular size={14} />,
plan: <IconPlanOutlineRegular />,
questions: <IconQuestionOutlineRegular />,
tools: <IconSparkleRegular size={14} />,
}
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 => <ChatNodeSeat key={`${seat.nodeKey}:${seat.assistantPart ?? ''}`} {...seatProps} {...seat} />)
: range.process
? <StepProcessSeat key={range.key} groupKey={range.key} useChat={useChat} {...seatProps} />
: <ChatNodeSeat key={range.key} {...seatProps} {...range.seats[0]} />)
})
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 : <StepProcess {...group} {...seatProps} />
})
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<ProcessScrollEdges>(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 (
<div ref={rootRef} className={css.root} data-chat-flow-key={range.key}
data-chat-anchor-key={range.key} data-chat-turn={range.turn} data-step-process>
<button type="button" className={css.title} aria-expanded={open} aria-controls={bodyId}
data-process-activity={activity}
onClick={(event) => { event.currentTarget.focus(); setOpen(!open) }}>
<span className={css.leading} aria-hidden="true">
<span className={css.activityIcon} data-step-process-icon>{PROCESS_ICONS[activity]}</span>
<span className={css.chevron} data-step-process-chevron>
{open ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
</span>
</span>
{range.closed
? <span className={css.label}>{title}</span>
: <TextShimmer className={css.label}>{title}</TextShimmer>}
</button>
<div ref={bodyRef} id={bodyId} className={bodyClasses.join(' ')} data-step-process-body
data-scroll-up={scrollEdges.canScrollUp || undefined}
data-scroll-down={scrollEdges.canScrollDown || undefined}
onScroll={syncScrollEdges}>
{range.seats.map(seat => <ChatNodeSeat key={`${seat.nodeKey}:${seat.assistantPart ?? ''}`} {...seatProps} {...seat} />)}
</div>
</div>
)
}
@@ -0,0 +1,50 @@
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 (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutlineRegular size={14} />}
chevronClassName={css.chevron}
title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')}
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<div className={css.body} data-system-prompt-body>
<OpaqueBody content={[{ type: 'text', text }]} source={null} t={t} />
</div>
</DisclosureRow>
)
}
/** System-prompt keyed Chat renderer. */
export const SystemPromptNodeView = memo(function SystemPromptNodeView({
node, t,
}: Pick<ChatNodeViewProps<'system-prompt'>, 'node' | 't'>) {
return <SystemPromptRow text={node.data.text} update={node.data.update === true} t={t} />
})
@@ -4,23 +4,14 @@
align-items: center;
width: 100%;
min-width: 0;
height: calc(33px + var(--dsh-content-font-delta, 0px));
height: 33px;
padding: 0 0 8px;
border: none;
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
background: none;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
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]) {
@@ -29,28 +20,28 @@
.chevron {
flex: none;
width: 14px;
height: 14px;
margin-left: 4px;
color: var(--dsw-alias-label-caption);
width: 16px;
height: 16px;
margin-left: 6px;
color: var(--dsw-alias-label-tertiary);
transform: rotate(-90deg);
transition: transform 100ms ease;
}
.root[data-open] .chevron {
transform: rotate(180deg);
transform: rotate(0deg);
}
.label {
min-width: 0;
overflow: hidden;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.root,
.chevron {
transition: none;
}
@@ -1,9 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { memo } 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. */
@@ -11,58 +8,53 @@ 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')
const open = turnProcess.open
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')
const open = turnProcess.open
const labels: string[] = []
if (node.data.toolCallCount > 0) {
labels.push(t(
node.data.toolCallCount === 1
? 'message.turnProcess.toolCalls.one'
: 'message.turnProcess.toolCalls.other',
{ count: node.data.toolCallCount },
))
}
if (node.data.messageCount > 0) {
labels.push(t(
node.data.messageCount === 1
? 'message.turnProcess.messages.one'
: 'message.turnProcess.messages.other',
{ count: node.data.messageCount },
))
}
if (node.data.subagentCount > 0) {
labels.push(t(
node.data.subagentCount === 1
? 'message.turnProcess.subagents.one'
: 'message.turnProcess.subagents.other',
{ count: node.data.subagentCount },
))
}
const label = labels.length === 0
? t('message.turnProcess.thoughtForAWhile')
: labels.join(t('message.turnProcess.separator'))
return (
<>
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
<button
type="button"
className={css.root}
data-open={open || undefined}
data-turn-process={node.data.turn}
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
disabled={!canCollapse}
aria-expanded={turnProcess.hasContent ? open : undefined}
onClick={(event) => {
event.currentTarget.focus()
turnProcess.setOpen(!open)
}}
>
<span className={css.label}>{label}</span>
{canCollapse && <IconChevronDownOutlineRegular className={css.chevron} />}
</button>
</>
<button
type="button"
className={css.root}
data-open={open || undefined}
data-turn-process={node.data.turn}
data-turn-process-messages={node.data.messageCount}
data-turn-process-tool-calls={node.data.toolCallCount}
data-turn-process-subagents={node.data.subagentCount}
aria-expanded={open}
onClick={(event) => {
event.currentTarget.focus()
turnProcess.setOpen(!open)
}}
>
<span className={css.label}>{label}</span>
<IconChevronDownOutlineRegular className={css.chevron} />
</button>
)
})
@@ -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.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 hasLaterChatNode = useChat(snapshot =>
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn)
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
@@ -37,7 +37,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
<div
className={css.root}
data-turn-tail={data.turn}
data-actions-reveal={endsWithResponse ? 'always' : 'hover'}
data-actions-reveal={isLatestTurn ? 'always' : 'hover'}
>
{tail}
<MessageIconActions
@@ -1,33 +0,0 @@
.root {
min-width: 0;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: 16px;
background: var(--dsw-alias-markdown-code-block);
transition: background-color 100ms ease;
}
.root:hover { background: var(--dsw-alias-interactive-bg-hover); }
.header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
border: 0;
background: none;
color: var(--dsw-alias-label-primary);
font: inherit;
text-align: left;
cursor: pointer;
}
.icon { display: inline-flex; flex: none; color: var(--dsw-alias-label-tertiary); }
.title { flex: none; font: var(--dsw-font-xs-13); }
.time { flex: none; margin-left: auto; color: var(--dsw-alias-label-caption); font: var(--dsw-font-xxs-12); }
.chevron, .openChevron { flex: none; color: var(--dsw-alias-label-tertiary); }
.openChevron { transform: rotate(180deg); }
.body { padding: 0 16px 12px 40px; }
.explanation { margin: 8px 0; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxs-12); }
.content { max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xxs-12); }
@media (prefers-reduced-motion: reduce) {
.root { transition: none; }
}
@@ -1,50 +0,0 @@
/** An independent, expandable notice explaining a non-human Turn trigger. */
import { useId, useState, type ComponentType } from 'react'
import {
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
type IconProps,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps } from '../contract/slots.ts'
import { formatMessageClock } from './message-chrome.ts'
import { ContextContentBody } from './ContextBody.tsx'
import { turnTriggerDetails, type TurnTriggerIcon } from './turn-trigger.ts'
import css from './TurnTriggerNodeView.module.css'
const TRIGGER_ICONS: Record<TurnTriggerIcon, ComponentType<IconProps>> = {
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 (
<section className={css.root} data-turn-trigger>
<button className={css.header} type="button" aria-expanded={open} aria-controls={bodyId} onClick={() => { setOpen(!open) }}>
<span className={css.icon} aria-hidden><TriggerIcon size={14} /></span>
<span className={css.title}>{t(details.title)}</span>
<time className={css.time} dateTime={date.toISOString()}>{time}</time>
<IconChevronDownOutlineRegular size={12} className={open ? css.openChevron : css.chevron} />
</button>
{open && <div id={bodyId} className={css.body}>
<p className={css.explanation}>{t('message.trigger.explanation')}</p>
<div className={css.content}><ContextContentBody content={node.data.content} t={t} /></div>
</div>}
</section>
)
}
@@ -8,10 +8,6 @@ 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')
}
@@ -59,24 +55,6 @@ 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.
@@ -5,11 +5,11 @@ import { NS } from '../locale.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, RetryNodeView, TurnErrorNodeView,
CompactionNodeView, ContextMessageNodeView, 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,7 +23,9 @@ 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: 'turn-trigger', locale: NS }, TurnTriggerNodeView))
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
@@ -1,31 +0,0 @@
/** 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
}
@@ -1,72 +0,0 @@
/** 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {}
}
function field(source: Record<string, unknown>, 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 }
}
@@ -1,14 +0,0 @@
/** 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')
}
@@ -78,9 +78,6 @@ 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
@@ -104,8 +101,6 @@ 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
}
@@ -3,7 +3,6 @@ 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,
@@ -75,8 +74,6 @@ 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
@@ -93,7 +90,6 @@ 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
@@ -121,7 +117,6 @@ 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,
@@ -1,60 +0,0 @@
/** 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<ProcessRange, 'closed'>
/** 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
}
@@ -3,10 +3,7 @@
/** Tool call identity as carried by Chat nodes. */
export type ToolCallId = string
/**
* Manual disclosure override for an expanded completed answer.
* Zero represents completion without an answer.
*/
/** One manually expanded Turn answer generation. */
export interface TurnProcessViewEntry {
readonly turn: number
readonly answerStep: number
@@ -20,7 +20,7 @@ export interface TurnProcessSpec {
const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
'system-prompt',
'user',
'steering', 'turn-trigger',
'steering',
'turn-process',
'turn-error',
'turn-max-tokens',
@@ -59,15 +59,3 @@ 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'
}
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more