Merge remote-tracking branch 'origin/ci/offload-coverage-to-vm-backup' into ci/offload-coverage-to-vm-backup

This commit is contained in:
Chinesezjc
2026-07-27 16:26:26 +08:00
45 changed files with 904 additions and 250 deletions

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
2026-07-23-toolview-dissolution.md: a420c5945d0272cf8087d5f623e9c383c286d7c2
2026-07-23-toolview-dissolution.zh.md: 47c1f392f5f7ddbf4e6c686b2574faa7987e6126
2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2
2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468

View File

@@ -14,7 +14,7 @@ After the view ring dissolved into the slot system, the client kept exactly one
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam — apply mounts `ConversationService` *after* the chat registration, so the service being present guarantees the slot is declared, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.

View File

@@ -14,7 +14,7 @@ Status: implemented
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`默认卡片是域产权fallback 选项就是普通 renderSlot 文法。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions``parentId`——决策放在已有全部信息的地方bash 样例即第三方姿态的样板。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`默认卡片是域产权fallback 选项就是普通 renderSlot 文法。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions``parentId`——决策放在已有全部信息的地方bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome`Bash · {description}`scoped badge 仅出现在子会话)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
registry 时代的职责各有后继居所inject 缓存与行错误隔离乘框架渲染器entry×scope 缓存、per-entry `SlotErrorBoundary`subscribe/getVersion 乘 slot core 的 per-key 版本机将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位交互草稿耐久性是其首个具名消费者miss 兜底即调用点 `fallback` 选项。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95
2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477

View File

@@ -154,7 +154,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
- Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.

View File

@@ -154,7 +154,7 @@ plugins:
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 下的前台 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即静默
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误TUI 快照固定 terminal 与 generic 卡片展示。
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。

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
2026-07-23-web-assistant-markdown.md: ce98a16fa43e2743c18826ee7f2344c38e7c70e7
2026-07-23-web-assistant-markdown.zh.md: 0d6fd2f9e6b91f76830586ecf4c29774e5d6978a
2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281
2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de

View File

@@ -12,13 +12,17 @@ The Web conversation preserves assistant Markdown source through session events,
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without `dangerouslySetInnerHTML`, raw-HTML parsing, or syntax highlighting. The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser is part of the initial browser bundle.
`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Citation pills, KaTeX, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers are out of scope until matching product DOM exists; GFM task lists keep native checkboxes.
The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle.
## Untrusted output policy
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline.
Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML).
The renderer uses existing `--dsw-*` typography and color tokens. Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column.
## Alternatives considered
@@ -30,6 +34,8 @@ The renderer uses existing `--dsw-*` typography and color tokens. Fenced code an
**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies.
**Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract.
## Consequences
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. The initial Web shell grows by the Markdown parser and GFM runtime, and future extensions such as syntax highlighting or remote media require a separate bundle and security decision.
Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, and shiki allowlist; cite/math/anchor/thinking-small surfaces remain deferred.

View File

@@ -12,13 +12,17 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
`MarkdownText` 使用 `react-markdown``remark-gfm`,从 AST 构建 React 元素。它支持 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,但不使用 `dangerouslySetInnerHTML`,不解析原始 HTML也不进行语法高亮。`ui-primitives` 显式声明该依赖;由于这一纯库由 Web shell 预置,解析器会成为初始浏览器 bundle 的一部分
`MarkdownText` 使用 `react-markdown``remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token高亮已注册语法否则回退为纯等宽文本。轮次流式输出期间围栏停留在纯文本分支以免每收到一个分片就对增长中的围栏重新分词
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md``markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*``--dsw-font-markdown-*``--dsw-alias-border-l*``--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`。引用胶囊、KaTeX、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记均不在范围内,直至存在匹配的产品 DOMGFM 任务列表继续使用原生复选框。
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
## 不受信任输出策略
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。
assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML
渲染器使用现有的 `--dsw-*` 排版与颜色 token。围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。
## 考虑过的替代方案
@@ -30,6 +34,8 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S
**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。
## 后果
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后流式输出都会重新解析当前文本未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。初始 Web shell 的体积会因加入 Markdown 解析器GFM 运行时而增大;语法高亮或远程媒体等后续扩展需要另行作出 bundle 与安全决策
assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后流式输出都会重新解析当前文本未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器GFM 运行时与 shiki 允许列表cite/math/anchor/thinking-small 表层仍暂缓

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-27-user-message-icon-actions.md: 869e7a2518a3ec927c0689a10816a410dc5f0862
2026-07-27-user-message-icon-actions.zh.md: 353e5ac765bb2fbab9932449cf2247768a1f412f

View File

@@ -0,0 +1,27 @@
# Agent Note: User-message IconActions under the bubble
Status: implemented
English | [中文](2026-07-27-user-message-icon-actions.zh.md)
## Problem
The chat user bubble had no under-bubble action chrome. The Harness design (figma `User_Bubble/message_container`) shows three IconActions — copy, branch in new chat, and edit — right-aligned under the bubble, matching the product action-bar pattern used elsewhere.
## Decision
`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests).
Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior.
Steering bubbles keep the badge-only form and do not show these actions.
## Alternatives considered
**Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths.
**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible.
## Consequences
User messages expose copy immediately; branch/edit remain clickable stubs until a later decision owns their behavior. Tests pin the three buttons, copy payload, and steering exclusion.

View File

@@ -0,0 +1,27 @@
# Agent Note: 用户消息气泡下方的 IconActions
Status: implemented
[English](2026-07-27-user-message-icon-actions.md) | 中文
## 问题
聊天用户气泡下方没有操作栏。Harness 设计稿figma `User_Bubble/message_container`)在气泡下方右对齐展示三个 IconActions——复制、在新对话中分支、编辑——与产品其他位置使用的操作栏模式一致。
## 决策
仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px先是气泡再是高度 28px 的操作行;行内间距 10px圆形图标按钮尺寸为 28px`IconCopyOutline16``IconBranchOutline16``IconEditOutline16`。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。
复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。
steering中途引导气泡保持仅徽章形态不展示这些操作。
## 考虑过的替代方案
**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。
**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。
## 后果
用户消息立即可用复制;分支/编辑仍为可点击的占位,直至后续决策明确其行为。测试钉死三个按钮、复制载荷,以及对 steering 的排除。

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 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
2026-07-26-ci-failover-runbook.md: b93c86d73f319f40706a4f9b31f448804e7b5ba8
2026-07-26-ci-failover-runbook.zh.md: 25b83981e70070800c5a4037807d26811e55124d
2026-07-26-ci-failover-runbook.md: 80dd7c4291e3de11c2f13b3247af56762396c720
2026-07-26-ci-failover-runbook.zh.md: 7933a857f1559c540fccc2cd89352c4fe351dd7e

View File

@@ -6,7 +6,7 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md)
## Problem
The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch a repository admin can throw without merging anything.
The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) and the required verdict job that aggregates them (`all checks passed`) run on the hosted enterprise 32-core pools. When those pools degrade — jobs queue indefinitely, the enterprise labels vanish, or GitHub-side capacity fails — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. An outage therefore needs a switch any responder with repository write access can throw without merging anything.
## Decision
@@ -16,7 +16,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity.
### Switch (repo admin, ~1 minute, no merge)
### Switch (any repository writer, ~1 minute, no merge)
1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`.
2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
@@ -28,7 +28,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver
## Capacity during failover
Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files**`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/` — then run `config.sh`; copying `.runner`/`.credentials` verbatim makes `config.sh` refuse with "already configured". `config.sh` only registers the instance — it starts no listener, so a runner that stops there is registered and offline, adding no capacity. Install and start its service too: `sudo ./svc.sh install <user> && sudo ./svc.sh start` (this pool is systemd-managed; a foreground `./run.sh` also works but dies with the shell). Confirm the instance reports Idle in org Settings → Actions → Runners before counting it. About a minute per instance.
Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files**`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/` — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance.
### Switch back
@@ -37,14 +37,14 @@ Delete the `DSH_CI_FAILOVER` variable (or set it to anything other than `selfhos
### Trust boundary
The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Runner-side enforcement — an org-level runner group restricting these runners to the master-ref workflow — is tracked separately and composes with this mechanism.
The variable is writer-manageable repository state; a pull request event itself can neither set it nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the four failover jobs are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.
## Alternatives considered
**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is admin-controlled state that takes effect on re-run without a merge.
**Merge a workflow change to switch pools.** Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge.
**Keep the self-hosted pool always in the required path.** Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variable keeps the hosted pools primary and the self-hosted pool a proven, one-action standby.
## Consequences
Recovering from a hosted-pool outage is a single admin variable plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg.
Recovering from a hosted-pool outage is a single variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology to keep working: the standby lane exercises it on every master push so the failover target never goes stale, and the concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg that must stay in step with the hosted leg.

View File

@@ -6,7 +6,7 @@ Status: implemented
## 问题
[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static``node 24 / coverage``node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个仓库管理员无需合并任何代码即可触发的开关。
[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static``node 24 / coverage``node 24 / snapshots and artifacts`)以及聚合它们的必需判定作业(`all checks passed`)运行在托管的企业级 32 核池上。当这些托管池发生故障——作业无限排队、企业标签消失或 GitHub 侧容量故障——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。
## 决策
@@ -16,7 +16,7 @@ Status: implemented
`vm-backup`:一台 64 核虚拟机6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。
### 切换步骤(仓库管理员,约 1 分钟,无需合并)
### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并)
1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`
2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run因此对于本手册所述的无限排队故障应取消卡住的运行并 re-run all jobs或推送一个新提交“Re-run failed jobs”只有在作业真正失败而非仍在排队时才有用。
@@ -28,7 +28,7 @@ Status: implemented
## 切换期间的容量
6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token组织 Settings → Actions → Runners → New runner追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/`——再跑 `config.sh`原样拷贝 `.runner`/`.credentials` 会使 `config.sh` 以 "already configured" 拒绝`config.sh` 只完成注册,不启动监听进程,因此停在这一步的 runner 处于已注册但离线状态,不增加任何容量。还须安装并启动其服务`sudo ./svc.sh install <user> && sudo ./svc.sh start`(本池由 systemd 管理;前台运行 `./run.sh` 亦可,但会随 shell 退出而终止)。确认该实例在组织 Settings → Actions → Runners 中显示 Idle 后再计入容量。每个约一分钟。
6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token组织 Settings → Actions → Runners → New runner追加注册实例。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/`——再跑 `config.sh`原样拷贝 `.runner`/`.credentials` 会使以 "already configured" 拒绝),然后**启动监听器**`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。
### 切回
@@ -37,14 +37,14 @@ Status: implemented
### 信任边界
该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot而非该变量。(运行器侧的组织级 runner group 约束另行跟踪,与本机制互补。)
该变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——四个故障切换作业是从 PR merge 引用求值的 `pull_request` 运行master 绑定的组会让它们持续排队2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。
## 曾考虑的替代方案
**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是管理员控制的状态,重跑即生效,无需合并。
**通过合并一次工作流改动来切换池。** 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。
**让自托管池长期处于必需路径中。** 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。该变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备。
## 后果
从托管池故障中恢复只需一个管理员变量加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。
从托管池故障中恢复只需一个变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它,使故障切换目标永不失效;而 `ci.yml` 中的并发与缓存恢复分支带有一条 `selfhosted` 支路,必须与托管支路保持同步。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-18-tui-terminal-state-snapshots.md: 8e86588f69fdb9d615232252ecf57309d440f1cd
2026-07-18-tui-terminal-state-snapshots.zh.md: b70a46830f44e9da663e30745fcdb7ad281592da
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md
2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1
2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b

View File

@@ -33,7 +33,7 @@ The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their pr
### Semantic terminal projection
The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix.
The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state. The streaming checkpoint freezes the loader interval while allowing real wall-clock delay across one animation tick, so it pins semantic status rather than whichever spinner glyph the scheduler happened to render.
Each expected output projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes.

View File

@@ -33,7 +33,7 @@ TUI 覆盖分为四个互补层次:
### 语义终端投影
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀
包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定。流式输出检查点会冻结 loader 的 interval同时保留跨过一次动画 tick 的真实墙钟等待,从而固定语义状态,而非调度器碰巧渲染出的某个加载动画字形
每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。

View File

@@ -427,12 +427,13 @@ jobs:
name: serial / linux (self-hosted standby)
runs-on: [self-hosted, linux, x64, vm-backup]
steps:
# fetch-depth 2 + DSH_ARCHIVE_BASE_REF below: same frozen-archive
# comparison as serial-linux — without the prior commit the archive
# verifier defaults to HEAD and compares the new manifest with itself.
# Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive
# comparison as serial-linux. Depth 2 would miss github.event.before
# on multi-commit or force pushes; full fetch is cheap here because
# checkout resolves against the VM's local mirror.
- uses: actions/checkout@v6
with:
fetch-depth: 2
fetch-depth: 0
- uses: actions/setup-node@v6
with:

View File

@@ -144,7 +144,7 @@ it('renders the fixture run_code turn: code parent row, nested sub-rows, error s
"errorSubRow": true,
"parentRow": "CodeRead the notes files and summarize",
"subRows": [
"$List notes",
"BashList notes",
"Readnotes/demo.txt",
"Readnotes/missing.txt",
],

View File

@@ -181,7 +181,8 @@ export function apply(ctx: Context): void {
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture.
// The bash sample rides that exact seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The todo_write row rides the same seam (a product registration, not a sample).

View File

@@ -37,17 +37,11 @@
border-radius: 6px;
}
/* Selection linkage: the selected call row wears the blue outline.
button-info-fill flips 500→400 with the theme, hitting the darker-blue
dark-mode spec exactly (business-primary stays 500 on both). */
.callRow[data-selected] {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 1px;
}
/* Selection still sets data-selected for details linkage; no outline
tool rows match Think chrome (no selected ring). */
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow
(same components, same selection outline as top-level rows). */
the code turn reads as one unit; each nested row is itself a .callRow. */
.subCalls {
display: flex;
flex-direction: column;

View File

@@ -1,10 +1,11 @@
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
(#EDF3FE light / dark pair rides the token sheet). */
/* User bubble: right-aligned column (bubble + IconActions). Figma
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
/* Block spacing is the flow column's gap alone — no extra padding here. */
.userRow {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.bubble {
@@ -19,6 +20,46 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;

View File

@@ -1,14 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo } from 'react'
import { memo, useCallback } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -26,6 +30,35 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
el.remove()
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -58,15 +91,52 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'user': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
</div>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}></span>}
<span className={css.badge}></span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>

View File

@@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
}
/**
* The dock entry as a plain registrant plugin (bash-sample posture).
* The dock entry as a plain registrant plugin (bash posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.

View File

@@ -323,7 +323,7 @@
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
color: var(--dsw-alias-label-primary);
}
.retry {

View File

@@ -1,29 +1,32 @@
/* Sample bash rows: deliberately distinct from ToolRow so the differential
registry hit is visible at a glance. */
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.row {
.root {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-family: var(--ds-font-family-code);
font-size: 13px;
}
.row:hover {
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.prompt {
.leading {
flex: none;
color: var(--dsw-alias-state-success-primary);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.scopeBadge {
flex: none;
margin-right: 8px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
@@ -32,17 +35,38 @@
background: var(--dsw-alias-state-business-primary);
}
.command {
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,35 +1,54 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
// Bash toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
const status = stateStatus(model.state)
return (
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-clickable
onClick={openDetails}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
)
}

View File

@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
// with the keyed-slot machinery specs since the tool ring dissolved into
// renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
afterEach(cleanup)
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'hello bubble' }] as never,
} as never}
/>,
)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('hello bubble')
})
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'fallback body' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
})
it('user copy stays quiet when execCommand throws or is absent', () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
render(
<MessageItem node={{
kind: 'user', seq: 1,
content: [{ type: 'text', text: 'quiet' }] as never,
} as never}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
const view = render(
<MessageItem node={{
kind: 'steering', seq: 2, turn: 1, source: null,
@@ -29,6 +98,7 @@ describe('MessageItem arms', () => {
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context and unknown nodes render their JSON rows', () => {

View File

@@ -156,12 +156,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
// description chrome, same as a top-level bash row — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})

View File

@@ -169,17 +169,19 @@ describe('bash sample row', () => {
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('summarizes the command and hands clicks to openDetails on both arms', () => {
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Bash')
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Bash')
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)

View File

@@ -156,6 +156,7 @@ describe('keyed toolview hole through the real machinery', () => {
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()

View File

@@ -264,7 +264,7 @@ describe('ChatView', () => {
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByText('run a'))

View File

@@ -1,13 +1,13 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, the node-half empty
// PendingCard question arm, bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -76,14 +76,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
// Root session (no parentId): the global arm renders, error pill visible.
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
@@ -91,12 +84,40 @@ describe('tails', () => {
current: undefined,
phase: 'ready',
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
const props = (block: RunningToolCall | ToolResultNode) => ({
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
} as unknown as ToolRowProps)
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
const stoppedResult: ToolResultNode = {
...errorResult,
error: { name: 'E', code: 'interrupted' },
}
const runningView = render(<BashRow {...props(running)} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(runningView.getByText('Bash')).toBeTruthy()
expect(runningView.getByText('List')).toBeTruthy()
runningView.unmount()
const errorView = render(<BashRow {...props(errorResult)} />)
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(errorView.getByText('失败')).toBeTruthy()
errorView.unmount()
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stoppedView.getByText('已停止')).toBeTruthy()
})
})

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
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Model Experience

View File

@@ -6,7 +6,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 模型体验

View File

@@ -1,13 +1,79 @@
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
and the fallback <pre> draw identically except for token colors. */
/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
.block {
--dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
--dsl-code-block-border-radius: 12px;
--dsl-code-block-banner-font: var(--dsw-font-xs-13);
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-code-block-border-radius);
}
.block:not(:last-child) {
margin-bottom: 11px;
}
.bannerWrap {
position: sticky;
top: 0;
z-index: 6;
background-color: var(--dsw-alias-bg-base);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.banner {
background: var(--dsl-code-block-banner-background-color);
padding: 9px 14px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font: var(--dsl-code-block-banner-font);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
}
.infostring {
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
}
.copyButton {
background-color: rgb(255 255 255 / 0);
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
font: inherit;
}
.block :where(pre) {
margin: 0;
padding: 8px 10px;
border-radius: 8px;
font: var(--dsl-code-block-content-font);
padding: 16px;
margin: 0 !important;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
}
/* Shiki inlines its theme background var; route it to the repo token. */
@@ -23,5 +89,4 @@
.plain {
color: var(--dsw-alias-label-primary);
white-space: pre;
}

View File

@@ -1,12 +1,10 @@
// CodeBlock: one code surface for every consumer — markdown fences, the
// run_code program body, and the details panel's raw args/output — with
// shiki highlighting for the registered grammars and an identical-geometry
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
// tree of nested spans whose colors are --shiki-* custom properties
// (token sheets own the values); it produces no scripts or event handlers,
// so injecting its output is safe by construction.
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useMemo } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { highlightToHtml } from './highlight.ts'
import css from './CodeBlock.module.css'
@@ -20,18 +18,80 @@ export interface CodeBlockProps {
className?: string | undefined
}
/** @returns true only when the host accepted the write. */
async function writeClipboard(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Denied permissions / iframe policy — do not claim success.
return false
}
}
// jsdom and older hosts: best-effort execCommand path when present.
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return false
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
return exec('copy')
} catch {
return false
} finally {
el.remove()
}
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
if (html === undefined) {
return (
<div className={clsx(css.block, className)}>
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
typed fallback if the DOM shape ever diverges. */
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => setCopied(false), 1000)
})
}, [copied, trimmed])
const body = html === undefined
? (
<pre className={css.plain}><code>{trimmed}</code></pre>
)
: (
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
<div className={css.bannerWrap}>
<div className={css.banner}>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
</div>
</div>
</div>
)
}
// eslint-disable-next-line react/no-danger -- shiki's output is a static
// span tree it generated from `code` (no user HTML passes through), the
// sanctioned innerHTML consumption path per shiki's own docs.
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
{body}
</div>
)
}

View File

@@ -1,95 +1,168 @@
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
intentionally absent (no matching DOM). Token names match that sheet. */
.markdown {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-base);
color: var(--dsw-alias-label-primary);
}
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
margin: 0;
.markdown strong {
font-weight: 600;
}
.markdown h1 {
font: var(--dsw-font-markdown-h1);
margin: 32px 0 16px;
}
.markdown h2 {
font: var(--dsw-font-markdown-h2);
margin: 32px 0 16px;
}
.markdown h3 {
font: var(--dsw-font-markdown-h3);
margin: 32px 0 16px;
}
.markdown :where(h4, h5, h6) {
.markdown h4 {
font: var(--dsw-font-markdown-h4);
margin: 16px 0;
}
.markdown :where(strong, th) {
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
.markdown :where(h5, h6) {
font: var(--dsw-font-markdown-base-strong);
margin: 16px 0;
}
.markdown :where(ul, ol) {
padding-inline-start: 24px;
.markdown :where(h1, h2, h3, h4, h5, h6) strong {
font-weight: inherit;
}
.markdown li + li {
margin-block-start: 4px;
.markdown p {
margin: 16px 0;
}
.markdown li > :where(ul, ol) {
margin-block-start: 4px;
/* Tighten h4h6 against a following list (design: 8px gap). */
.markdown :where(h4, h5, h6) + :where(ul, ol) {
margin-top: 8px;
}
.markdown blockquote {
padding-inline-start: 12px;
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
color: var(--dsw-alias-label-secondary);
.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) {
margin-bottom: 8px;
}
.markdown a {
/* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
keeps design-platform brand-text as near-black, so links use the blue
business-primary alias instead. */
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-offset: 2px;
transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
position: relative;
text-decoration: none;
/* Transparent hit-area padding; literal zero-alpha only (no painted color). */
border-left: 3px solid rgb(255 255 255 / 0);
border-right: 3px solid rgb(255 255 255 / 0);
border-top: 2px solid rgb(255 255 255 / 0);
border-bottom: 2px solid rgb(255 255 255 / 0);
margin-left: -3px;
margin-right: -3px;
}
.markdown :not(pre) > code {
padding: 2px 4px;
border-radius: 4px;
background: var(--dsw-alias-markdown-inline-code);
font: var(--dsw-font-markdown-code);
.markdown a:hover,
.markdown a:focus {
outline: none;
text-decoration: underline var(--dsw-alias-state-business-primary);
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
padding: 12px 16px;
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
font: var(--dsw-font-markdown-code-block);
.markdown a:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
}
.markdown pre code {
padding: 0;
background: transparent;
font: inherit;
overflow-wrap: normal;
word-break: normal;
white-space: pre;
.markdown :where(ul, ol) {
margin: 16px 0;
padding-left: 18px;
}
.markdown li:not(:first-child) {
margin-top: 6px;
}
.markdown li > :where(ul, ol) {
margin-top: 4px;
}
.markdown li::marker {
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */
.markdown :where(ul, ol) ol {
list-style-position: inside;
padding-left: 0;
}
.markdown :where(ul, ol) ol li p {
display: inline;
}
.markdown li > p {
margin: 8px 0;
}
.markdown li > *:first-child {
margin-top: 0;
}
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
.markdown li > *:last-child:not(:global(.md-code-block)) {
margin-bottom: 0;
}
.markdown hr {
width: 100%;
border: 0;
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
display: block;
border: none;
height: 1px;
margin: 32px 0;
background: var(--dsw-alias-border-l2);
}
.markdown blockquote {
border-left: 2px solid var(--dsw-alias-label-caption);
margin: 16px 0 0;
padding-left: 14px;
}
.markdown pre {
margin: 16px 0;
font-family: var(--ds-font-family-code);
overflow: auto;
}
.markdown :not(pre) > code {
display: inline-flex;
align-items: center;
box-sizing: border-box;
font: var(--dsw-font-markdown-code);
font-family: var(--ds-font-family-code);
font-size: 0.875em !important;
background-color: var(--dsw-alias-markdown-inline-code);
border-radius: 6px;
padding: 0 5px;
}
.markdown :where(h1, h2, h3, h4, h5, h6) code {
font: inherit;
font-family: var(--ds-font-family-code);
}
.markdown input[type='checkbox'] {
margin: 0 8px 0 0;
accent-color: var(--dsw-alias-state-business-primary);
accent-color: var(--dsw-alias-label-secondary);
}
.tableScroll {
@@ -99,22 +172,52 @@
}
.tableScroll table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
font: var(--dsw-font-markdown-table);
}
.tableScroll :where(th, td) {
padding: 6px 12px;
border: 1px solid var(--dsw-alias-markdown-citation);
text-align: start;
white-space: nowrap;
width: max-content;
max-width: max-content;
}
.tableScroll th {
background: var(--dsw-alias-markdown-code-block-banner);
text-align: start;
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
border-top: none;
font: var(--dsw-font-markdown-table-head);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll td {
padding: 10px 16px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
font: var(--dsw-font-markdown-table);
max-width: 320px;
max-width: min(30vw, 320px);
min-width: 100px;
}
.tableScroll th:first-child,
.tableScroll td:first-child {
padding-left: 0;
}
.tableScroll td:last-child {
padding-right: 0;
}
.tableScroll table code {
font-size: 13px;
}
.markdown > *:first-child,
.markdown p:first-child {
margin-top: 0 !important;
}
.markdown > *:last-child,
.markdown p:last-child {
margin-bottom: 0 !important;
}
.imageAlt {

View File

@@ -5,14 +5,17 @@
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
// alongside the rest of the markdown family.
import { describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
describe('highlightToHtml', () => {
it('highlights a registered grammar into css-variables token spans', () => {
const html = highlightToHtml('const x: number = 1', 'typescript')
@@ -50,4 +53,86 @@ describe('CodeBlock', () => {
expect(view.container.querySelector('pre.shiki')).toBeNull()
expect(view.getByText('plain text')).toBeTruthy()
})
it('shows the language banner and copies the pre textContent', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
expect(screen.getByText('ts')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('const a = 1')
// Flush the clipboard promise under fake timers before asserting the label.
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('does not claim success when clipboard.writeText rejects', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('falls back to execCommand when clipboard.writeText is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const exec = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: exec,
})
render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(exec).toHaveBeenCalledWith('copy')
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when execCommand throws or is absent', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: () => {
throw new Error('denied')
},
})
const denied = render(<CodeBlock code="plain body" />)
fireEvent.click(denied.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(denied.getByRole('button', { name: '复制' })).toBeTruthy()
denied.unmount()
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: undefined,
})
const absent = render(<CodeBlock code="plain body" />)
fireEvent.click(absent.getByRole('button', { name: '复制' }))
await Promise.resolve()
expect(absent.getByRole('button', { name: '复制' })).toBeTruthy()
expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull()
})
})

View File

@@ -57,8 +57,10 @@ describe('MarkdownText', () => {
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
expect(container.querySelector('hr')).not.toBeNull()
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
// The ts fence routed through the shared CodeBlock: shiki token spans present.
// The ts fence routed through the shared CodeBlock: shiki token spans + banner.
expect(container.querySelector('pre.shiki')).not.toBeNull()
expect(screen.getByText('ts')).toBeTruthy()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(container.querySelector('br')).not.toBeNull()
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()

View File

@@ -9,5 +9,7 @@
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration: 0.2s;
--ds-transition-duration-fast: 0.1s;
--ds-transition-duration-slow: 0.3s;
}

View File

@@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent {
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
async function harness(
mode: 'danger-full-access' | 'workspace-write',
timing: { idleSilenceMs?: number; timeoutMs?: number } = {},
) {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
@@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
idleSilenceMs: timing.idleSilenceMs ?? 250,
timeoutMs: timing.timeoutMs ?? 2_000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
@@ -63,8 +66,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
const deadline = Date.now() + 2_000
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let output = ''
while (!output.includes(expected) && Date.now() < deadline) {
output += operation.readOutput().delta
@@ -131,20 +134,25 @@ describe('pty-local real shell', () => {
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access')
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 10_000,
timeoutMs: 15_000,
})
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const controller = new AbortController()
const ready = 'RAW_READY'
// Delay readiness beyond the shared harness's short send bound so this
// process test owns enough slack for loaded macOS startup and shell echo.
// The interactive shell echoes the command, so only child output may contain the readiness marker.
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_" + "READY", flush=True); time.sleep(60)\''
const command = 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\''
expect(command).not.toContain(ready)
const foreground = ctx.pty.startSend(agent, created.sessionId, {
text: command,
submit: true,
signal: controller.signal,
})
await waitForOutput(foreground, ready)
await waitForOutput(foreground, ready, 15_000)
controller.abort()
const result = await foreground.done
expect(result.waitReason).toBe('stdin_read')
@@ -155,5 +163,5 @@ describe('pty-local real shell', () => {
expect(after.viewport).toContain('AFTER_SIGINT')
expect(after.waitReason).toBe('stdin_read')
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
}, 20_000)
})

View File

@@ -229,33 +229,45 @@ const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7
describe('TUI terminal-state snapshots', () => {
it('pins an in-flight reasoning and Markdown stream', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
// Freeze the loader's first animation interval so this semantic snapshot
// cannot select a different spinner frame under scheduler contention.
const frozenLoaderTimer = setInterval(() => {}, 60_000)
const intervals = vi.spyOn(globalThis, 'setInterval').mockImplementationOnce(() => frozenLoaderTimer)
try {
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
await checkpoint('conversation-streaming', harness.terminal)
await disposeSnapshot(harness)
const loaderIntervalMs = intervals.mock.calls[0]?.[1]
if (typeof loaderIntervalMs !== 'number') throw new Error('TUI loader did not register an animation interval')
await new Promise(resolve => setTimeout(resolve, loaderIntervalMs + 5))
await checkpoint('conversation-streaming', harness.terminal)
} finally {
intervals.mockRestore()
clearInterval(frozenLoaderTimer)
await disposeSnapshot(harness)
}
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {