diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index fff8e98bc5..cdf124f72a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28 -2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9 +2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779 +2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 703960cb24..8befe840b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall **Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. -**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row. **Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. @@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | -| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| successful standard foreground call | terminal output, exit code/signal, and settled state presentation | | `run_in_background:true` | Generic row and raw result | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 7f16137955..249366f10e 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa **Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 -**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。 **对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 @@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | -| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | | `run_in_background:true` | Generic 行与原始结果 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml index 89a705592d..55f30d278a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md -2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0 -2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af +2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db +2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md index 14128497cf..db8cb9a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo `Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control. -`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. +`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md). -**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does. +**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction. The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too. ## Finding the duplicates -A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches. +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class. ## What stays local @@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial: - **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`. -- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. - **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. - **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. - **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so. @@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in **A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. -**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. +**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. **Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule. @@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in `Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. -The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. +The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`. -That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. +The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes. ## Consequences - A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. - `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. -- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. -- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. +- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight. +- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback. - The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. -- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. +- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md index 66268b48f8..6d4b04a21b 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -18,15 +18,15 @@ Status: implemented `Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。 -`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。 +`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。 -**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。 +**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。 规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。 ## 如何找出重复 -按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。 +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。 ## 保留在原包的控件 @@ -34,7 +34,6 @@ Status: implemented 机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似: - **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。 -- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 - **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 - **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 - **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。 @@ -52,7 +51,7 @@ Status: implemented **给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 -**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 +**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 **把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。 @@ -60,15 +59,15 @@ Status: implemented `Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 -迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 +迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 -这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 +组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。 ## Consequences - 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 - `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 -- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 -- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 +- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。 +- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。 - 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 -- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 +- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml new file mode 100644 index 0000000000..758bca04da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md +2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1 +2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md new file mode 100644 index 0000000000..3850fcbddf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md @@ -0,0 +1,29 @@ +# Agent Note: Size-neutral product icon weights + +Status: implemented + +English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md) + +## Problem + +Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features. + +## Decision + +Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source. + +`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export. + +Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode. + +## Alternatives considered + +**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry. + +**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable. + +**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library. + +## Consequences + +The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides. diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md new file mode 100644 index 0000000000..84031677e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尺寸无关的产品图标线重 + +Status: implemented + +[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文 + +## 问题 + +共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。 + +## 决策 + +每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。 + +`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。 + +Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。 + +## 考虑过的替代方案 + +**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。 + +**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。 + +**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。 + +## 后果 + +图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 1ef5c7c433..e678639894 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: fd2ff912a83f2535e33472e2b5755b9d059ba674 -2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e +2026-07-27-web-subagent-conversations.md: aacb52eca5680534b74abd42ca07c2b568c23e21 +2026-07-27-web-subagent-conversations.zh.md: 30c41f03703004e3175cee8fad9d11e0cf93f06a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index fd2ff912a8..aacb52eca5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -39,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies the shared tertiary-grey ongoing loader and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0124ce4143..30c41f0370 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -39,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品约定 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示共享的三级灰色 ongoing loading,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 6009b3c1ad..11220477c5 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062 -2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49 +2026-09-04-web-clickable-link-styles.md: eddceb8af8b39b24ebf85870b893ecd4d7939c96 +2026-09-04-web-clickable-link-styles.zh.md: dbb2c9ac34605e45ef0f404628ac710384983b57 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index c4e506294c..eddceb8af8 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,13 +13,13 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — `LinkIconMedium` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, code and web extensions share the code glyph, and spreadsheet extensions use the document glyph. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. -Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. +Coverage: a link-icon unit spec (one distinct glyph per kind, both stroke weights, known-site marks, and the classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. ## Alternatives considered @@ -30,6 +30,6 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Consequences -- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). +- A new clickable artifact surface should consume `--dsw-alias-link` and the `LinkIconMedium` vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). - Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. - mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 01258674b4..dbb2c9ac34 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,13 +13,13 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 的 `LinkIconMedium`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码和网页扩展名共用 code 图形,表格扩展名使用 document 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 -覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 +覆盖:link-icon 单测(每个 kind 一个独立图形、两种描边字重、已知站点标记和分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 ## 备选方案 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 +- 新的可点击产物表面应消费 `--dsw-alias-link` 和 `LinkIconMedium` 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 - 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 - mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml index a84c4d396a..44e214ff4c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md -2026-09-07-composer-session-stats-pills.md: 22f78dfdde854baaf1ced8c20c55042952df8c26 -2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c +2026-09-07-composer-session-stats-pills.md: 49976439e6c30603423278a86dba80c3c48eb39b +2026-09-07-composer-session-stats-pills.zh.md: 09b956c6607ebebb10a525161090f434b735d233 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md index 22f78dfdde..49976439e6 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -12,7 +12,8 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed. -- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster. - **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent. - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing. - **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md index 6b71c6e7cf..09b956c660 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -12,7 +12,8 @@ Status: implemented `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。 -- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。 - **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。 - **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml index a42e0ee0bd..93a617d08b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md -2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e -2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224 +2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md index 3cccb3c038..50b97ce2c7 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/ The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands". -`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. +`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. -The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. +The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md index 31c879b17a..92bcb04bfe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列 「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。 -`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 -菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf3876efca..8b9255eec8 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f -2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 +2026-09-08-shared-file-type-icons.md: f0d3fbdb3a86b5f2c67a438de96348b30b27319f +2026-09-08-shared-file-type-icons.zh.md: d7542592804bc384e4ae5d7359145e70f4d98527 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 1e3fe72679..f0d3fbdb3a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md) ## Problem -Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins. ## Decision `ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. -`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. -`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. +`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces. ## Alternatives considered -**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. +**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. -**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. +**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. **Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. @@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index fd1f38ebb3..d754259280 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 ## 决策 `ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 -`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 -`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 +`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。 ## 备选方案 -**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 +**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 -**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 +**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 **把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 @@ -32,7 +32,7 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml index 7d4eb35fa1..992a5d55e9 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md -2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b -2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5 +2026-09-10-connection-indicator-refinements.md: 7c918b6f9ee65612c627e0406230a99e20cde2af +2026-09-10-connection-indicator-refinements.zh.md: e6a3fcdec50bdd209c73679b47d4b05fde88a9a4 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md index 3b6b4c7fd0..7c918b6f9e 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md @@ -10,7 +10,7 @@ The sidebar connection pill hid its affordance behind a hover swap: outage and r ## Decision -**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. +**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutlineRegular`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. **The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md index 0bd5dc3e0b..e6a3fcdec5 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 +**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutlineRegular`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 **外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 4ed53fa01a..50233385bf 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: d3bb7d173450d4c27af4b5b65e16f33cc285605a -2026-09-14-desktop-primary-runtime.zh.md: 2df6effdccc09a488e322bfe6f144e18b101369c +2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 +2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index d3bb7d1734..51bbaac89c 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -18,7 +18,7 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. -Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. +Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion. Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. @@ -30,8 +30,12 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe **Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable. +**Multi-file SignTool requests.** A software-certificate probe with a missing first file still signs the second file before returning an error. One process cannot guarantee stopping hardware operations at the first failure; token signing remains one file per supervised request. + **Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution. +Dependency manifests use electron-builder's own cleanup before inventory sealing. Its archive writer otherwise changes package metadata after signing; pre-applying that transformation keeps the final archive verifiable without replacing the sealed inventory after packaging. + ## Consequences The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 2df6effdcc..4f27d61b5e 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -18,7 +18,7 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 -Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。 +Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 @@ -30,8 +30,12 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0 **独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。 +**多文件 SignTool 请求。** 软件证书探针在第一个文件不存在时,仍会先给第二个文件签名,再返回错误。单个进程无法保证首个失败后停止硬件操作;令牌签名仍按每个受监督请求处理一个文件。 + **仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。 +依赖清单在记录完整性清单前使用 electron-builder 自身的清理逻辑。否则其归档写入器会在签名后修改包元数据;提前应用该转换可让最终归档通过校验,无需在打包后替换已封装的完整性清单。 + ## Consequences 应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml index 513a59328a..d6e17b52fa 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md -2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84 -2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4 +2026-09-16-known-site-link-marks.md: 5e4bae0349f28d823c78854a435061de7872b358 +2026-09-16-known-site-link-marks.zh.md: 7b57ab0bbc4491d0db597a31f5077db07c4eb142 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md index 9d029af06f..5e4bae0349 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md @@ -10,7 +10,7 @@ Every transcribed anchor led with the same globe, so a transcript full of GitHub ## Decision -`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. +`LinkIconRegular` and `LinkIconMedium` take an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. `SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe. diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md index 2c7630d122..7b57ab0bbc 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 +`LinkIconRegular` 与 `LinkIconMedium` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。 diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml new file mode 100644 index 0000000000..cf44de1cb6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md +2026-09-17-compact-translucent-menu-surfaces.md: 08fcb2d851e4c75ce9069fc797e8dbcb070acc5b +2026-09-17-compact-translucent-menu-surfaces.zh.md: 8e13634995d46b7414f17bbb51443df85cb3ac3c diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md new file mode 100644 index 0000000000..08fcb2d851 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md @@ -0,0 +1,29 @@ +# Agent Note: Compact translucent menu surfaces + +Status: implemented + +English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md) + +## Problem + +Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages. + +## Decision + +`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. A surface containing fixed-position overlays paints the fill and filter on an isolated background pseudo-element, because a filtered ancestor would otherwise change those overlays' containing block. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill. + +The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line. + +The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings. + +## Alternatives considered + +**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`. + +**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family. + +**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation. + +## Consequences + +Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule, either on the surface or on its isolated background pseudo-element; the stylesheet gate rejects a material layer that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing. diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md new file mode 100644 index 0000000000..8e13634995 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 紧凑半透明菜单表面 + +Status: implemented + +[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文 + +## Problem + +共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。 + +## Decision + +`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。包含 fixed 定位浮层的表面把填充与滤镜绘制在隔离的背景伪元素上,因为带滤镜的祖先会改变这些浮层的包含块。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。 + +共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。 + +WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。 + +## Alternatives considered + +**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。 + +**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。 + +**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。 + +## Consequences + +高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token,规则可以属于表面本身,也可以属于其隔离的背景伪元素;样式表门禁会拒绝漏掉 filter 的材质层。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml new file mode 100644 index 0000000000..d86602a99e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md +2026-09-17-state-dot-visual-language.md: 751abbaa33919721c7e71738ad9b6bed744fcad7 +2026-09-17-state-dot-visual-language.zh.md: 9746ec9af79e15fc41ea8e6601cc52c1cfafba02 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md new file mode 100644 index 0000000000..751abbaa33 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot solid marks and loading spinner + +Status: implemented + +English | [中文](2026-09-17-state-dot-visual-language.zh.md) + +## Problem + +`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase. + +## Decision + +`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the neutral `--dsw-alias-state-idle-primary` token, `done` remains success green, `warning` remains amber, and `error` remains red. + +`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change. + +Converted compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state; their collapsed summary turns red for failure and amber for `stopped`, preserving any tool-specific interruption text. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available. + +## Alternatives considered + +**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state. + +**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo. + +## Testing + +The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Tool, Bash, and Skill row specs pin the retained business glyph plus visible red failure and amber interruption summaries. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels. + +## Consequences + +Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state. diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md new file mode 100644 index 0000000000..9746ec9af7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot 纯色标记与旋转 loading + +Status: implemented + +[English](2026-09-17-state-dot-visual-language.md) | 中文 + +## 问题 + +`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。 + +## 决策 + +`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用中性的 `--dsw-alias-state-idle-primary` token,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。 + +`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。 + +已转换的紧凑纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标;收起摘要在失败时变红,在 `stopped` 时变为琥珀色并保留工具自有的中断文本。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。 + +## 考虑过的替代方案 + +**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。 + +**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。 + +## 测试 + +组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Tool、Bash 与 Skill 行测试固定保留的业务图形,以及可见的红色失败摘要与琥珀色中断摘要。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。 + +## 后果 + +消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml new file mode 100644 index 0000000000..04d246c108 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md +2026-09-17-windows-runtime-signature-cache.md: 909e39b5c06ed9d529df7553de324295aadf43fe +2026-09-17-windows-runtime-signature-cache.zh.md: c7ee5e1ca5105c21e3a7f4b43342dee20331e88b diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md new file mode 100644 index 0000000000..909e39b5c0 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -0,0 +1,35 @@ +# Agent Note: Windows runtime signature cache + +Status: implemented + +English | [中文](2026-09-17-windows-runtime-signature-cache.zh.md) + +## Problem + +Windows packaging reconstructs unsigned native dependencies on each build. Signing unchanged Python and LibreOffice files repeats hardware-token and timestamp operations. Starting PowerShell for each public-key inspection also repeats process initialization. + +## Decision + +Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide. + +Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request. + +The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation. + +## Alternatives considered + +**Multi-file hardware signing.** The primary-runtime decision records that SignTool can continue after the first file fails. Reducing repeated hardware work through caching preserves the existing stop behavior. + +**Detached signature extraction and attachment.** Complete-file caching avoids adding PE-signature manipulation to packaging. Its additional disk use is measurable and confined to build storage. + +**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes. + +## Consequences + +Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files. + +Six complete signed Windows x64 builds cover an empty signature cache, repeated inputs, application changes and one controlled native-file content change. Warm builds reduce hardware calls from 379 to 17 and elapsed time from 37:38 to 12:36–12:42 on the measured host, while final runtime checks pass. Download caches remain populated; the controlled file change tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate. + +## Risks + +The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight. diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md new file mode 100644 index 0000000000..c7ee5e1ca5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows 运行时签名缓存 + +Status: implemented + +[English](2026-09-17-windows-runtime-signature-cache.md) | 中文 + +## Problem + +Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 Python 和 LibreOffice 文件签名会重复执行硬件令牌操作和时间戳请求。每个公钥验签任务单独启动 PowerShell,也会重复进程初始化。 + +## Decision + +按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。 + +公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 + +[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 + +## Alternatives considered + +**多文件硬件签名。** 主要运行时决策记录了 SignTool 在首个文件失败后仍继续处理的行为。通过缓存减少重复硬件操作,可以保留现有停止语义。 + +**分离签名的提取与附加。** 缓存完整文件可以避免给打包流程新增 PE 签名处理代码。其额外磁盘占用可测量,且限于构建存储。 + +**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。 + +## Consequences + +原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。 + +六轮完整 Windows x64 签名构建覆盖空签名缓存、重复输入、应用变化和单个原生文件的受控内容变化。在测量主机上,热构建将硬件调用从 379 次降至 17 次,耗时从 37:38 降至 12:36–12:42,且最终运行时检查通过。下载缓存保持已填充;受控文件变化验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。 + +## Risks + +缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml new file mode 100644 index 0000000000..c8a392f733 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-signature-completion.md +2026-09-17-windows-signature-completion.md: e9c81531633a41c87ea10c9e4af2999923aa4470 +2026-09-17-windows-signature-completion.zh.md: 3142749db309b638676c171d4b1da8bd34729fe4 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md new file mode 100644 index 0000000000..e9c8153163 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md @@ -0,0 +1,35 @@ +# Agent Note: Separate Windows hardware signing from timestamp completion + +Status: implemented + +English | [中文](2026-09-17-windows-signature-completion.zh.md) + +## Problem + +A combined SignTool command reports timestamp failure as signing failure. Retrying it repeats hardware access, while treating every timestamp error as an uncertain private-key outcome requires administrator recovery. Public timestamp-only requests can fail independently of the token. + +## Decision + +Sign a private PE copy once without a timestamp. Release the hardware interlock only after successful execution and Windows verification of the configured primary signature. Hardware or primary-verification failures retain the interlock; no path automatically recovers it. Appended signatures are unsupported. + +Timestamp fresh copies of the frozen signed file without signing credentials. Only normal failure or warning exits permit another attempt: at most three calls, with one- and two-second delays. Launch failures, uncertain termination, storage changes and verification failures stop immediately. The preflight's overall 60-second deadline includes these operations and does not guarantee three full subprocess deadlines. + +Require Windows trust, timestamp and certificate identity. Remove unsigned signature attributes from private copies with the configured SignTool and compare their complete normalized bytes. A removal warning is accepted only when all bytes remain unchanged. Certificate equality alone cannot exclude different content signed by the same certificate; full-file equality avoids a custom PE or ASN.1 canonicalizer. + +SignTool uses short filenames with the original extension in a private temporary directory. Its longest generated path is checked before hardware access, and the journal records the original-to-staged mapping. The target may occupy another volume: copy verified bytes into a private directory beside it, compare hashes, recheck the original and rename within that volume. Verify committed bytes afterward. The build account must own trusted storage and exclusive target access. + +Exhausted timestamp attempts stop packaging and queued work without another hardware call. Failure evidence is copied to the supervised run before staging cleanup; evidence or audit failure preserves staging. Final artifact verification and release completion remain mandatory. The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) owns runtime contents and vendor signatures. + +## Alternatives considered + +**Retry combined signing or classify diagnostic text.** Exit codes and localized text cannot establish the private-key outcome. Separate operations avoid repeated hardware access. + +**Reuse failed timestamp output.** A failed process may partially modify its file. Every attempt starts with identical verified primary-signature bytes. + +**Stage beside each target for all operations.** Nested dependencies can exceed SignTool's path limit even when the original fits. Short tool paths and same-volume publication address separate requirements. + +## Consequences + +Fault tests cover lock retention, isolated timestamp retries, queue rejection, credential exclusion, substituted content, changed targets, long paths and evidence failures. Public-only qualification covers EXE, DLL, PYD and NODE files, including failure followed by success and cross-volume publication. A real supervised signing preflight has passed; complete installer comparisons remain separate qualification. This decision does not identify or repair the timestamp service's network failures. + +DigiCert [supports separate signing and timestamping](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems). Microsoft documents [SignTool commands and exit codes](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). The configured tool and normalization implementation participate in signature-cache identity. diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md new file mode 100644 index 0000000000..3142749db3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md @@ -0,0 +1,35 @@ +# Agent Note:分离 Windows 硬件签名与时间戳处理 + +Status: implemented + +[English](2026-09-17-windows-signature-completion.md) | 中文 + +## 问题 + +合并的 SignTool 命令把时间戳失败报告为签名失败。重试会重复访问硬件,而把所有时间戳错误都视为私钥操作结果不确定,又需要管理员恢复。公开时间戳请求可以在不使用令牌的情况下独立失败。 + +## 决策 + +对私有 PE 副本仅签名一次,不请求时间戳。只有命令成功且 Windows 验证配置证书的主签名后,才释放硬件互锁。硬件或主签名验证失败保留互锁;任何路径都不自动恢复互锁。不支持追加签名。 + +从固定的已签名文件生成新副本并请求时间戳,不传递签名凭据。只有正常退出但返回失败或警告时才允许再次尝试:最多调用三次,间隔为一秒和两秒。启动失败、终止状态不确定、存储变化和验签失败会立即停止。预检整体 60 秒期限包含这些操作,不保证每次子进程都能获得完整期限。 + +要求 Windows 信任、时间戳和证书身份均通过验证。使用配置的 SignTool 删除私有副本的未签名属性,再比较完整的规范化字节。删除警告仅在所有字节未变时接受。仅比较证书无法排除由同一证书签署的不同内容;全文件相等检查避免自行实现 PE 或 ASN.1 规范化器。 + +SignTool 在私有临时目录中使用保留原扩展名的短文件名。访问硬件前检查生成的最长路径,日志记录原文件与暂存文件的对应关系。目标可以位于另一个卷:将已验证字节复制到目标旁的私有目录,比较摘要,重新检查原文件,再在同一卷内重命名。随后验证提交后的字节。构建账户必须拥有受信任的存储和目标独占访问权。 + +时间戳尝试耗尽后,停止打包和排队任务,不再次调用硬件。清理暂存目录前,将失败证据复制到受监督运行目录;证据保存或审计失败时保留暂存目录。最终产物验签和发布完成检查仍是必需步骤。[第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)负责运行时内容与上游签名。 + +## 考虑过的替代方案 + +**重试合并签名命令或按诊断文本分类。** 退出码和本地化文本无法确定私钥操作结果。分离操作避免重复访问硬件。 + +**复用失败的时间戳输出。** 失败进程可能已部分修改文件。每次尝试均从相同的已验证主签名字节开始。 + +**所有操作都在目标旁暂存。** 即使原路径未超限,嵌套依赖的暂存路径也可能超出 SignTool 限制。短工具路径和同卷发布分别满足不同要求。 + +## 影响 + +故障测试覆盖互锁保留、独立时间戳重试、队列拒绝、凭据隔离、内容替换、目标变化、长路径和证据保存失败。仅使用公开操作的验收覆盖 EXE、DLL、PYD 和 NODE 文件,包括先失败后成功及跨卷发布。真实受监督签名预检已通过;完整安装包对比仍需单独验收。此决策不确定或修复时间戳服务的网络故障。 + +DigiCert [支持分开签名与添加时间戳](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems)。Microsoft 记录了 [SignTool 命令及退出码](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool)。配置的工具和规范化实现均参与签名缓存身份计算。 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml index 5c2380b3d6..6f8e8d20c4 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md -2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad -2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8 +2026-09-17-plugin-inventory-phase-dot.md: 5ba614c61a390bbd39a49c17239d0410012b325d +2026-09-17-plugin-inventory-phase-dot.zh.md: a01d172c6b6f3cb461b79ef456b37a17b9c324c8 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md index e90d49b6a3..5ba614c61a 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md @@ -32,4 +32,4 @@ Row enablement, tag tones, grouping, search and list behavior are unchanged, as The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`. -The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). +The remaining dots' palette and animation are owned by [the StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md); the tag capsule remains owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md index d344ede75c..a01d172c6b 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md @@ -32,4 +32,4 @@ Status: implemented 包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。 -其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。 +其余圆点的配色与动画由 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责;标签胶囊仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)负责。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 97d4bf97f7..7c68d9199d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 200190c8df..5014681e9a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ef03c189f9..f4b0246e8e 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: e1045abca7501c7e0db40f164a0d65bf36fd1725 -README.zh.md: d3ba41a05dc533c671ad1ce6859f21821d7ebff2 +README.md: 123e27d1d03bf218835af310cb529365cdc47b94 +README.zh.md: aee40ffa4ceae673a931f076d668debfdf83de2d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e1045abca7..123e27d1d0 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -18,7 +18,7 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies -Signed Windows packaging preserves valid vendor signatures and signs unsigned PE executables, DLLs, Python extensions and Node addons in the primary runtime before executing its smoke checks. Each new signature must match the configured certificate and carry a timestamp; invalid existing signatures, signing errors and verification errors stop the run without retries. Electron-builder preserves copied runtime executables only after checking their signature and exact equality with the prepared file, preventing repeat signing during resource copying. Checks include decimal, XML, LZMA, UUID, numpy and pandas. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. A passing smoke does not establish compatibility for every extension or enterprise policy. +Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. @@ -46,7 +46,7 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. @@ -142,11 +142,11 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t ### Runtime file selection -Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. @@ -210,7 +210,9 @@ Run `pnpm --dir apps/desktop run test:installer` from the repository root on an ### Windows EV signing -Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. A 60-second preflight deadline, signing error or verification failure stops the run without retry. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. +Runtime signing caches complete signed files under `.desktop-build/targets/win-x64/signature-cache`. Entries identify the original bytes, public certificate and signing toolchain. Restoring an entry checks its digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. The cache requires trusted local build storage and retains entries until removed. Preflight, hardware interlocks and final artifact signing still apply. Cache qualification and design limits are tracked in the [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md). + +Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately. Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. @@ -220,7 +222,7 @@ For this project's SafeNet token, `SignTool Error: No private key is available.` Windows package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `.desktop-build/packaging-runs/`. Each run retains `run.json`, timestamped `events.jsonl`, redacted `stdout.log` and `stderr.log`, and `result.json`. A signing failure also writes `fatal.json` and notifies the parent through stderr; the supervisor immediately requests termination of its stage process tree and waits for exit. Failed stages cannot start subsequent stages or create a release completion record. Journal failures also stop the run. Termination errors remain failures and require operator inspection; a missing final record means completion was not established. -Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing releases that file. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. +Hardware signing requires a supervised run. Before invoking the command interpreter, the signer atomically acquires `%USERPROFILE%/.dsh-desktop-signing/attempt.json` and records the attempt. Only successful signing followed by verification of the configured primary signature releases that file; timestamps are completed afterward without hardware access. Failure, interruption, an existing interlock, or unavailable audit storage prevents further hardware access, including from another signer instance, process, or checkout under the same Windows account. There is no timed recovery or automatic retry. An administrator must inspect the retained evidence and token state before explicitly authorizing interlock recovery; logging into the token or replacing a PIN file does not clear it. The records distinguish signing intent, command-interpreter PID, and completion; they do not measure internal CSP/token authentication attempts. Command arguments, PINs, and credential environments are not recorded. Independent Windows accounts and unrelated signing programs are outside this interlock. Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. @@ -232,9 +234,11 @@ Fill in `.env.windows` with `DSH_DESKTOP_WINDOWS_CER_FILE` (public EV leaf certi pnpm run package:desktop:win:x64 ``` -Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. +Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated primary-runtime signing stage and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. +Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). + +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: @@ -251,7 +255,7 @@ pnpm run prepare:desktop This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d3ba41a05d..aee40ffa4c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -18,7 +18,7 @@ macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含 ### 内置工作区依赖 -Windows 签名打包保留有效的上游签名,并在执行冒烟检查前,为第一方运行时中未签名的 PE 可执行文件、DLL、Python 扩展和 Node 插件补签。每个新签名必须匹配配置的证书且带时间戳;已有签名无效、签名错误或验签错误都会停止本轮执行,不自动重试。electron-builder 只有在校验复制后运行时可执行文件的签名、且文件与已准备的源文件逐字节一致后,才保留其签名,避免复制资源时重复签名。检查覆盖 decimal、XML、LZMA、UUID、numpy 和 pandas。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表所有扩展或企业策略都兼容。 +Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 @@ -46,7 +46,7 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 @@ -143,11 +143,11 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS ### 运行时文件筛选 -Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 [Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。 Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。 @@ -211,7 +211,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI ### Windows EV 签名 -Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检超过 60 秒、签名报错或验签失败都会停止本轮流程,不重试。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 +运行时签名将完整的已签名文件缓存在 `.desktop-build/targets/win-x64/signature-cache`。缓存项标识原始字节、公钥证书和签名工具链。恢复缓存项时,先检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。缓存要求受信任的本地构建存储,并保留缓存项直到被移除。预检、硬件互锁和最终产物签名仍然执行。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)记录缓存验收要求和设计限制。 + +Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。 Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 @@ -221,7 +223,7 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/packaging-runs/` 下的唯一目录。每次运行保留 `run.json`、带时间戳的 `events.jsonl`、脱敏后的 `stdout.log` 和 `stderr.log`,以及 `result.json`。签名失败还会写入 `fatal.json` 并通过 stderr 通知父进程;监督程序立即请求终止当前阶段的进程树并等待退出。失败阶段不能启动后续阶段或生成发布完成记录。日志写入失败也会停止运行。终止错误仍按失败处理,需要操作者检查;缺少最终记录表示尚未确认完成。 -硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功才释放该文件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 +硬件签名必须属于受监督的打包运行。调用命令解释器前,签名器原子获取 `%USERPROFILE%/.dsh-desktop-signing/attempt.json` 并记录本次尝试。只有签名成功且配置证书的主签名通过验证后才释放该文件;随后完成时间戳,不再访问硬件。失败、中断、已有锁定文件或审计存储不可用都会阻止再次访问硬件,包括同一 Windows 账户下的另一个签名器实例、进程或代码检出目录。没有定时恢复或自动重试。管理员必须检查保留的证据及令牌状态,再明确授权恢复锁定状态;登录令牌或替换 PIN 文件不会清除它。记录区分签名意图、命令解释器 PID 和完成结果,不计量 CSP/令牌内部的认证次数。不记录命令参数、PIN 或凭据环境。其他 Windows 账户及无关签名程序不在此锁定机制的保护范围内。 Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 @@ -233,7 +235,9 @@ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自 pnpm run package:desktop:win:x64 ``` -打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 +打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 + +时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 @@ -252,7 +256,7 @@ pnpm run prepare:desktop 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index b305647650..f7cfd63f42 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -1,3 +1,5 @@ +import type { AfterPackContext, BeforePackContext } from 'app-builder-lib' + /** Electron-builder fields asserted by the Desktop release tests. */ export interface DesktopElectronBuilderConfig { readonly appId: string @@ -45,7 +47,9 @@ export interface DesktopElectronBuilderConfig { readonly installerLanguages: readonly ['en_US', 'zh_CN'] } readonly beforeBuild: () => Promise - readonly beforePack: (context: { readonly appOutDir: string }) => Promise + readonly beforePack: (context: BeforePackContext) => Promise + readonly afterPack: (context: AfterPackContext) => Promise + readonly afterSign: (context: AfterPackContext) => Promise readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3925ab33c1..c060f1c673 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index ea33c60b2a..cf2f76768f 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,3 +1,5 @@ +import { X509Certificate } from 'node:crypto' +import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execFile } from 'node:child_process' @@ -19,7 +21,9 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' -import { preserveWindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs' +import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs' +import { recordPackagingEvent } from './packaging-run.mjs' import { resolveMacOSAppUpdateFeed, verifyMacOSAppUpdateConfig, @@ -57,17 +61,23 @@ export function createElectronBuilderConfig( if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) let primaryRuntimeDestination + let dshDestination + let windowsCode = [] + const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep/bin/rg'] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL, tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, - preserveSignature: async path => primaryRuntimeDestination === undefined ? false : preserveWindowsRuntimeSignature(path, { - sourceRoot: join(buildPaths.runtime, 'primary-runtime'), - destinationRoot: primaryRuntimeDestination, - runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR, - }), + preserveSignature: async path => { + for (const [sourceRoot, destinationRoot] of [[join(buildPaths.runtime, 'primary-runtime'), primaryRuntimeDestination], [buildPaths.dsh, dshDestination]]) { + if (destinationRoot !== undefined && await preserveWindowsRuntimeSignature(path, { + sourceRoot, destinationRoot, runDirectory: env.DSH_DESKTOP_PACKAGING_RUN_DIR, + })) return true + } + return false + }, }) : undefined if (windowsSigner !== undefined) { @@ -108,12 +118,7 @@ export function createElectronBuilderConfig( // electron-builder excludes a source directory's root node_modules. { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }, ], - asarUnpack: [ - '**/*.{node,dylib,dll,so,exe}', - '**/*.so.*', - '**/spawn-helper', - '**/@vscode/ripgrep/bin/rg', - ], + asarUnpack: unpack, extraResources: [ { from: buildPaths.runtime, to: 'runtime' }, { from: fileURLToPath(new URL('../resources/icon-windows.png', import.meta.url)), to: 'icon.png' }, @@ -134,29 +139,36 @@ export function createElectronBuilderConfig( writeUpdateInfo: false, }, beforePack: async context => { - if (windowsSigner !== undefined) primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') + if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh) + if (windowsSigner !== undefined) { + primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime') + dshDestination = join(context.appOutDir, 'resources', 'app.asar.unpacked', 'dsh') + } if (policy === undefined) return const { resolveDesktopPolicyConfig } = await import('../lib/types/mandatory-update-policy.js') resolveDesktopPolicyConfig(policy) }, afterPack: async context => { - const { verifyDesktopRuntime, writeDesktopRuntime } = await import('../lib/types/runtime-tree.js') + const { verifyDesktopRuntime } = await import('../lib/types/runtime-tree.js') const resourcesDir = context.packager.getResourcesDir(context.appOutDir) if (resolvedPlatform === 'darwin' && update !== undefined) { await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish), context.packager.appInfo.updaterCacheDirName) } - if (resolvedPlatform === 'win32' && !unsigned) { - // Windows signs copied executable resources before afterPack runs. - const prepared = await verifyDesktopRuntime(buildPaths.dsh, - context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) - writeDesktopRuntime(buildPaths.dsh, prepared.release, prepared.sharedPackages.map(entry => entry.name), - { platform: resolvedPlatform, arch: resolvedArch }) - } await verifyDesktopRuntime(buildPaths.dsh, context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + // Unsigned Windows builds skip electron-builder's afterSign hook. + if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode) }, afterSign: async context => { + if (windowsSigner !== undefined) { + await signWindowsCode(context.appOutDir, { + thumbprint: new X509Certificate(await readFile(env.DSH_DESKTOP_WINDOWS_CER_FILE)).fingerprint.replaceAll(':', ''), + sign: windowsSigner, + record: event => recordPackagingEvent(env.DSH_DESKTOP_PACKAGING_RUN_DIR, event), + }) + await verifyWindowsAsarUnpack(buildPaths.dsh, context.packager.getResourcesDir(context.appOutDir), windowsCode) + } if (context.electronPlatformName !== 'darwin') return const appPath = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) if (update !== undefined) { diff --git a/apps/desktop/scripts/installed-update-packaging.ts b/apps/desktop/scripts/installed-update-packaging.ts index a1c61a5d76..f0660e6f96 100644 --- a/apps/desktop/scripts/installed-update-packaging.ts +++ b/apps/desktop/scripts/installed-update-packaging.ts @@ -55,7 +55,7 @@ async function inputHashes(manifest: string, version: string, environment: NodeJ const paths = [manifest, join(run.root, 'application/result.json'), join(run.root, version, 'dsh/desktop-runtime.json'), join(REPOSITORY, 'pnpm-lock.yaml'), join(APP_ROOT, 'package.json'), ...['electron-builder-config.mjs', 'installed-update-builder.ts', 'build-installed-update-worker.mjs', - 'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-directory-installer.mjs', + 'windows-sign.mjs', 'windows-sign.cmd', 'windows-signing-state.mjs', 'windows-timestamp.mjs', 'windows-directory-installer.mjs', 'installer.nsh', 'prepare-windows-installer.ps1'].map(path => join(import.meta.dirname, path)), environment.DSH_DESKTOP_WINDOWS_CER_FILE!, environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!] const files = [] diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 0cbf4c6898..aede75a697 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -361,7 +361,8 @@ export async function packageTarget( await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], targetEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv) await execute(['run', 'prepare:packages'], targetEnv) - await execute(['run', 'prepare:dsh'], targetEnv) + await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], targetEnv) + if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return if (target.platform === 'darwin' && !invocation.directory) { await execute([ @@ -377,6 +378,7 @@ export async function packageTarget( } else { await execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) } + if (signPrimaryRuntime) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index e5c789c165..d1124618d6 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,6 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join, relative, resolve } from 'node:path' @@ -17,7 +17,8 @@ import { verifyDesktopCoreLockfile, } from '../src/core-package-set.ts' import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' -import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' +import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts' import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { resolveDesktopAppId, @@ -147,18 +148,14 @@ async function main(): Promise { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) } + await prepareRuntimeManifests(DSH_OUTPUT_ROOT) smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')) writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) - await new Promise((accept, reject) => { - execFile(NODE, ['--expose-internals', join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT], - { timeout: 120_000, env: desktopNodeEnvironment(NODE, join(RUNTIME_ROOT, 'bin'), { ...process.env, NODE_OPTIONS: '' }) }, (error, stdout, stderr) => { - if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error })) - else { process.stdout.write(stdout); accept() } - }) - }) - await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) - await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + if (!process.argv.includes('--defer-runtime-smoke')) { + await smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor) + await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + } } catch (error) { rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) throw error diff --git a/apps/desktop/scripts/prepare-runtime-manifests.ts b/apps/desktop/scripts/prepare-runtime-manifests.ts new file mode 100644 index 0000000000..7f919b24fd --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime-manifests.ts @@ -0,0 +1,26 @@ +/** Apply electron-builder's dependency metadata cleanup before sealing runtime bytes. */ +import { readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +// electron-builder omits declarations for its internal manifest transformer. +const { createTransformer } = createRequire(import.meta.url)('app-builder-lib/out/fileTransformer.js') as { + createTransformer: (root: string, config: object, extraMetadata: undefined) => + (path: string) => Promise | null +} + +/** + * Materialize the same manifest bytes electron-builder will put in the archive. + * @param root Prepared application dependency tree. + * @returns Resolves after dependency manifests are ready for integrity inventory. + */ +export async function prepareRuntimeManifests(root: string): Promise { + const transform = createTransformer(root, {}, undefined) + const modules = join(root, 'node_modules') + for (const entry of await readdir(modules, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || entry.name !== 'package.json') continue + const path = join(entry.parentPath, entry.name) + const transformed = await transform(path) + if (transformed != null) await writeFile(path, transformed) + } +} diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index f196f56126..2f93208c60 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -1,85 +1,47 @@ /** Sign Windows runtime code before executing it, retaining vendor signatures and fail-stop hardware protection. */ import { X509Certificate } from 'node:crypto' -import { lstat, open, readdir, readFile } from 'node:fs/promises' -import { extname, join, resolve } from 'node:path' +import { readFile, realpath } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { createWindowsTokenSigner } from './windows-sign.mjs' -import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs' +import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { smokePrimaryRuntime } from './prepare-primary-runtime.ts' +import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' /** - * Enumerate Windows code without following links or treating foreign .node files as PE binaries. - * @param root - Owned, materialized runtime directory. - * @returns Sorted real PE files; rejects links and malformed Windows executable files. + * Sign the bundled interpreters before their smoke checks. + * @param root Materialized primary runtime. + * @param options Supervised signer, certificate identity, audit sink and runtime check. + * @returns Resolves after verified signatures and a successful smoke. */ -export async function windowsRuntimeCode(root: string): Promise { - const rootStat = await lstat(root) - if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('primary runtime: expected a real directory') - const files: string[] = [] - for (const entry of await readdir(root, { withFileTypes: true })) { - const path = join(root, entry.name) - if (entry.isSymbolicLink()) throw new Error(`primary runtime: directory links are not signable: ${path}`) - if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } - if (!entry.isFile() || !['.exe', '.dll', '.pyd', '.node'].includes(extname(path).toLowerCase())) continue - const file = await open(path, 'r') - let portableExecutable = false - let windowsCandidate = false - try { - const header = Buffer.alloc(64) - const { bytesRead } = await file.read(header, 0, header.length, 0) - windowsCandidate = bytesRead >= 2 && header.readUInt16LE(0) === 0x5a4d - if (bytesRead === 64 && windowsCandidate) { - const signature = Buffer.alloc(4) - const offset = header.readUInt32LE(0x3c) - const read = await file.read(signature, 0, 4, offset) - portableExecutable = offset >= 64 && read.bytesRead === 4 && signature.readUInt32LE(0) === 0x4550 - } - } finally { await file.close() } - if (portableExecutable) files.push(path) - else if (windowsCandidate || extname(path).toLowerCase() !== '.node') throw new Error(`primary runtime: invalid PE file: ${path}`) - } - return files.sort() -} - -interface RuntimeSigningOptions { - thumbprint: string - sign: ReturnType - inspect?: (path: string) => Promise - record: (event: object) => void - smoke: (root: string) => void -} - -/** - * Preserve valid signatures and sign only unsigned PE files before runtime execution. - * @param root - Owned final runtime directory. - * @param options - Supervised signer, certificate identity, audit sink and runtime check. - * @returns Resolves only after sequential signatures, verification and execution; no retries. - */ -export async function signWindowsPrimaryRuntime(root: string, options: RuntimeSigningOptions): Promise { - const inspect = options.inspect ?? inspectWindowsRuntimeSignature - const files = await windowsRuntimeCode(root) - if (files.length === 0) throw new Error('primary runtime: no Windows code found') - const unsigned: string[] = [] - for (const path of files) { - const signature = await inspect(path) - options.record({ type: 'primary-runtime-signature', path, ...signature }) - if (signature.status === 'NotSigned') unsigned.push(path) - else if (signature.status !== 'Valid') throw new Error(`primary runtime: refusing ${signature.status} signature: ${path}`) - } - options.record({ type: 'primary-runtime-signing-plan', files: files.length, unsigned: unsigned.length }) - for (const path of unsigned) { - await options.sign({ path, hash: 'sha256', isNest: false }) - const signature = await inspect(path) - if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { - throw new Error(`primary runtime: signing verification failed: ${path}`) - } - options.record({ type: 'primary-runtime-signature-verified', path, ...signature }) - } +export async function signWindowsPrimaryRuntime( + root: string, options: WindowsCodeSigningOptions & { smoke: (root: string) => void }, +): Promise { + await signWindowsCode(root, options) options.smoke(root) options.record({ type: 'primary-runtime-smoke-success' }) } +/** + * Seal application dependency hashes only after all Windows code is signed. + * @param root Materialized production dependency directory. + * @param version Expected release version. + * @param options Supervised signing dependencies and a smoke receiving the signed descriptor. + * @returns Resolves after final inventory verification and smoke; any failure stops the stage. + */ +export async function signWindowsDesktopRuntime(root: string, version: string, + options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise }): Promise { + const descriptor = await verifyDesktopRuntime(root, version) + await signWindowsCode(root, options) + writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name)) + const signed = await verifyDesktopRuntime(root, version) + await options.smoke(signed) + await verifyDesktopRuntime(root, version) +} + async function main(): Promise { if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64') const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR @@ -89,13 +51,29 @@ async function main(): Promise { if (!certificateFile) throw new Error('primary runtime signing requires the configured certificate') const thumbprint = new X509Certificate(await readFile(certificateFile)).fingerprint.replaceAll(':', '') try { - await signWindowsPrimaryRuntime(join(resolveDesktopTargetBuildPaths().runtime, 'primary-runtime'), { + const paths = resolveDesktopTargetBuildPaths() + const record = (event: object): void => { recordPackagingEvent(runDirectory, event) } + const sign = createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL, + keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN }) + const identity = await signatureCacheIdentity([ + await realpath(certificateFile), await realpath(process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL!), + join(import.meta.dirname, 'windows-sign.cmd'), join(import.meta.dirname, 'windows-sign.mjs'), + join(import.meta.dirname, 'windows-timestamp.mjs'), + ]) + const options = { thumbprint, - sign: createWindowsTokenSigner({ certificateFile, signTool: process.env.DSH_DESKTOP_WINDOWS_SIGNTOOL, - keyContainer: process.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, tokenPin: process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN }), - record: (event) => { recordPackagingEvent(runDirectory, event) }, - smoke: smokePrimaryRuntime, - }) + sign: createCachedSigner({ root: join(paths.root, 'signature-cache'), identity, thumbprint, + sign, inspect: inspectWindowsRuntimeSignature, record }), + record, + } + if (process.argv.includes('--dsh')) { + const version = JSON.parse(await readFile(join(paths.dsh, 'package.json'), 'utf8')).version as string + await signWindowsDesktopRuntime(paths.dsh, version, { ...options, + smoke: descriptor => smokePreparedRuntime(paths.dsh, join(paths.electron, 'electron.exe'), paths.runtime, descriptor), + }) + } else { + await signWindowsPrimaryRuntime(join(paths.runtime, 'primary-runtime'), { ...options, smoke: smokePrimaryRuntime }) + } } catch (error) { failPackagingRun(runDirectory, 'primary-runtime-signing-or-smoke-failed') throw error diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts new file mode 100644 index 0000000000..5fc993f25d --- /dev/null +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -0,0 +1,13 @@ +/** Validate the assembled Windows application before recording release completion. */ +import { join } from 'node:path' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { verifyWindowsCode } from './windows-runtime-signature.mjs' +import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' + +const paths = resolveDesktopTargetBuildPaths() +const application = join(paths.artifacts, 'win-unpacked') +const descriptor = await verifyDesktopRuntime(paths.dsh, readDesktopRuntime(paths.dsh).release.version) +await verifyWindowsCode(application) +await smokePreparedRuntime(join(application, 'resources', 'app.asar', 'dsh'), + join(application, 'DeepSeek Harness.exe'), join(application, 'resources', 'runtime'), descriptor) diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts new file mode 100644 index 0000000000..94f0c1e55e --- /dev/null +++ b/apps/desktop/scripts/smoke-prepared-runtime.ts @@ -0,0 +1,36 @@ +/** Check payloads and Host boot with private native-cache and Harness directories. */ +import { execFile } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { desktopNodeEnvironment } from '../src/node-environment.ts' +import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' +import { smokeDesktopRuntime } from './smoke-runtime.ts' + +/** + * Exercise real runtime files and Host composition, including an ASAR root when packaged. + * @param root Prepared or archived dsh directory. + * @param node Target Electron executable. + * @param resourcesRuntime External runtime directory beside the archive. + * @param descriptor Runtime descriptor already verified against the selected target, which may differ from the build host. + * @returns Resolves after payload checks, Host readiness and teardown. + */ +export async function smokePreparedRuntime( + root: string, node: string, resourcesRuntime: string, descriptor: DesktopRuntimeDescriptor, +): Promise { + const cache = await mkdtemp(join(tmpdir(), 'desktop-native-smoke-')) + const environment = { ...scrubWindowsSigningEnvironment(process.env), NODE_OPTIONS: '', + NARB_NATIVE_CACHE_DIR: cache, NARB_DISABLE_NATIVE_CACHE: '0' } + try { + const { stdout } = await promisify(execFile)(node, [ + '--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime, + ], { timeout: 120_000, windowsHide: true, + env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }) + process.stdout.write(stdout) + await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime) + } finally { + await rm(cache, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index cf3d848d7f..f3697cf521 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -12,11 +12,18 @@ import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' * @param root - Materialized dsh resources. * @param node - Prepared target Electron executable. * @param runtime - Verified resource descriptor. + * @param environment - Credential-scrubbed build environment and private native cache. + * @param resourcesRuntime - Bundled interpreters outside the application archive. */ -export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise { +export async function smokeDesktopRuntime( + root: string, node: string, runtime: DesktopRuntimeDescriptor, environment: NodeJS.ProcessEnv, resourcesRuntime: string, +): Promise { const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) const profile = join(home, 'profiles', 'desktop') - const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home }) + const host = new DesktopHostProcess(node, root, profile, undefined, { ...environment, DSH_HOME: home }, + undefined, join(resourcesRuntime, 'primary-runtime'), 'runtime', + { pnpm: join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.cjs'), nodeBin: join(resourcesRuntime, 'bin') }) + let timer: ReturnType | undefined try { createPluginProfile(profile) const pluginName = 'desktop-runtime-smoke-plugin' @@ -45,7 +52,10 @@ export function apply(ctx) { manifest.dsh.profile.bundles.push(pluginName) writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) writeFileSync(join(profile, 'cordis.patch.yml'), '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n') - const ready = await host.start() + const ready = await Promise.race([host.start(), new Promise((_, reject) => { + timer = setTimeout(() => { reject(new Error('desktop runtime: Host readiness exceeded 120 seconds')) }, 120_000) + })]) + clearTimeout(timer) const login = await fetch(ready.url, { redirect: 'manual' }) const cookie = login.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') const response = await fetch(new URL('/', ready.url), { headers: { cookie } }) @@ -55,6 +65,7 @@ export function apply(ctx) { const pluginResponse = await fetch(new URL('/desktop-smoke', ready.url), { headers: { cookie } }) if (await pluginResponse.text() !== 'plugin route ready') throw new Error('desktop runtime: plugin HTTP route failed') } finally { + clearTimeout(timer) await host.stop() rmSync(home, { recursive: true, force: true }) } diff --git a/apps/desktop/scripts/windows-asar-unpack.d.mts b/apps/desktop/scripts/windows-asar-unpack.d.mts new file mode 100644 index 0000000000..984c055737 --- /dev/null +++ b/apps/desktop/scripts/windows-asar-unpack.d.mts @@ -0,0 +1,18 @@ +import type { BeforePackContext } from 'app-builder-lib' + +/** + * Install source-relative PE patterns in the active builder configuration. + * @param context Active builder configuration and cleanup owner. + * @param sourceRoot Verified prepared dsh directory; external sources receive a build-owned staging copy. + * @returns PE paths relative to the original prepared directory. + */ +export function prepareWindowsAsarUnpack(context: BeforePackContext, sourceRoot: string): Promise + +/** + * Reject inline, absent, linked, or changed PE files in the assembled application. + * @param sourceRoot Original signed and sealed dsh directory. + * @param resourcesDir Assembled application resources directory. + * @param files PE paths returned by prepareWindowsAsarUnpack. + * @returns Resolves after every PE has an unpacked ASAR entry and identical bytes. + */ +export function verifyWindowsAsarUnpack(sourceRoot: string, resourcesDir: string, files: string[]): Promise diff --git a/apps/desktop/scripts/windows-asar-unpack.mjs b/apps/desktop/scripts/windows-asar-unpack.mjs new file mode 100644 index 0000000000..d406c27235 --- /dev/null +++ b/apps/desktop/scripts/windows-asar-unpack.mjs @@ -0,0 +1,70 @@ +/** Keep prepared Windows PE files outside ASAR without changing their sealed bytes. */ +import { cp, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import { windowsRuntimeCode } from './windows-runtime-signature.mjs' + +function inside(root, file) { + const suffix = relative(root, file) + return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix) +} + +function unpackPattern(path) { + // Builder removes backslash escapes; brace expansion also ignores character classes. + // A brace matches one character, so similarly named neighbors may also be unpacked. + return path.split(sep).join('/').replace(/[\[\]{}()*?+@#]/gu, + character => character === '{' || character === '}' ? '?' : `[${character}]`).replace(/^!/u, '@(!)') +} + +/** + * Install source-relative PE patterns in the configuration consumed by electron-builder. + * @param {import('app-builder-lib').BeforePackContext} context Active builder configuration and cleanup owner. + * @param {string} sourceRoot Verified prepared dsh directory; its files remain unchanged. + * @returns {Promise} PE paths relative to the original prepared directory. + */ +export async function prepareWindowsAsarUnpack(context, sourceRoot) { + const files = (await windowsRuntimeCode(sourceRoot)).map(file => relative(sourceRoot, file)) + const { config, info } = context.packager + const { appDir } = info + let copyRoot = sourceRoot + if (!inside(appDir, sourceRoot)) { + // Builder's source matcher slices the appDir prefix even for external FileSets. + const parent = join(appDir, '.desktop-build') + await mkdir(parent, { recursive: true }) + const stage = await mkdtemp(join(parent, 'asar-source-')) + copyRoot = join(stage, 'dsh') + info.disposeOnBuildFinish(() => rm(stage, { recursive: true, force: true })) + await cp(sourceRoot, copyRoot, { recursive: true, force: false, errorOnExist: true }) + config.files = config.files.map(file => { + if (typeof file === 'string' || file.from === undefined) return file + const from = resolve(appDir, file.from) + return inside(sourceRoot, from) ? { ...file, from: join(copyRoot, relative(sourceRoot, from)) } : file + }) + } + const existing = config.asarUnpack ?? [] + config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), + ...files.map(file => unpackPattern(relative(appDir, join(copyRoot, file))))] + return files +} + +/** + * Reject inline, absent, linked, or changed PE files in the assembled application. + * @param {string} sourceRoot Original signed and sealed dsh directory. + * @param {string} resourcesDir Assembled application resources directory. + * @param {string[]} files PE paths returned by prepareWindowsAsarUnpack. + * @returns {Promise} Resolves after every PE has an unpacked ASAR entry and identical bytes. + */ +export async function verifyWindowsAsarUnpack(sourceRoot, resourcesDir, files) { + const archive = await readAsar(join(resourcesDir, 'app.asar')) + for (const file of files) { + const entry = archive.getFile(join('dsh', file), false) + if (entry.unpacked !== true || entry.link !== undefined) { + throw new Error(`Windows ASAR: PE must be unpacked: ${file}`) + } + const copied = join(resourcesDir, 'app.asar.unpacked', 'dsh', file) + const stat = await lstat(copied) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Windows ASAR: expected a real PE file: ${file}`) + const [prepared, packaged] = await Promise.all([readFile(join(sourceRoot, file)), readFile(copied)]) + if (!prepared.equals(packaged)) throw new Error(`Windows ASAR: PE bytes changed: ${file}`) + } +} diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index 7f9d9cf797..9c6e207a17 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -24,3 +24,34 @@ export function preserveWindowsRuntimeSignature(path: string, options: { runDirectory: string inspect?: typeof inspectWindowsRuntimeSignature }): Promise + +/** Supervised signing dependencies shared by all Windows release trees. */ +export interface WindowsCodeSigningOptions { + thumbprint: string + sign: ReturnType + inspect?: typeof inspectWindowsRuntimeSignature + record: (event: object) => void +} + +/** + * Enumerate PE files by content without following links. + * @param root Materialized directory to scan. + * @returns Sorted PE paths; rejects malformed Windows executables and links. + */ +export function windowsRuntimeCode(root: string): Promise + +/** + * Preserve valid signatures and sign unsigned PE files with a supervised signer. + * @param root Materialized directory to sign. + * @param options Signer, certificate identity and audit sink. + * @returns Resolves after verified sequential signing; rejects without retries. + */ +export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise + +/** + * Reject unsigned or invalid PE files in a completed directory. + * @param root Materialized artifact directory. + * @param inspect Public-key verifier. + * @returns Resolves after all PE signatures are valid. + */ +export function verifyWindowsCode(root: string, inspect?: typeof inspectWindowsRuntimeSignature): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index 04b1d7dbb8..ee70330fd7 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -1,10 +1,11 @@ /** Inspect runtime signatures and preserve byte-identical copies made by electron-builder. */ import { execFile } from 'node:child_process' -import { readFile, realpath } from 'node:fs/promises' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { lstat, open, readdir, readFile, realpath } from 'node:fs/promises' +import { extname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import { recordPackagingEvent } from './packaging-run.mjs' +import { inspectSignaturesBatched } from './windows-signature-batch.mjs' /** * Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key. @@ -22,13 +23,13 @@ export async function inspectWindowsRuntimeSignature(path) { if (stderr || typeof value !== 'object' || value === null || typeof value.status !== 'string' || typeof value.timestamped !== 'boolean' || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) { - throw new Error(`primary runtime: invalid signature inspection: ${path}`) + throw new Error(`Windows code: invalid signature inspection: ${path}`) } return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint } } /** - * Preserve a copied primary-runtime executable only after signature and exact-byte verification. + * Preserve a copied runtime executable only after signature and exact-byte verification. * @param {string} path Signing-hook target. * @param {{sourceRoot: string, destinationRoot: string, runDirectory: string, inspect?: typeof inspectWindowsRuntimeSignature}} options Prepared and copied runtime roots with retained audit directory. * @returns {Promise} True for a verified runtime copy; false for targets outside that directory. @@ -38,12 +39,105 @@ export async function preserveWindowsRuntimeSignature(path, options) { if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) return false const source = join(options.sourceRoot, suffix) for (const file of [source, path]) { - if (await realpath(file) !== resolve(file)) throw new Error(`primary runtime: linked copy is not signable: ${file}`) + if (await realpath(file) !== resolve(file)) throw new Error(`Windows code: linked copy is not signable: ${file}`) } const [prepared, copied] = await Promise.all([readFile(source), readFile(path)]) - if (!prepared.equals(copied)) throw new Error(`primary runtime: copied executable changed: ${path}`) + if (!prepared.equals(copied)) throw new Error(`Windows code: copied executable changed: ${path}`) const signature = await (options.inspect ?? inspectWindowsRuntimeSignature)(path) - if (signature.status !== 'Valid') throw new Error(`primary runtime: copied signature is ${signature.status}: ${path}`) + if (signature.status !== 'Valid') throw new Error(`Windows code: copied signature is ${signature.status}: ${path}`) recordPackagingEvent(options.runDirectory, { type: 'primary-runtime-copy-verified', path, ...signature }) return true } + +/** + * Enumerate Windows code without following links or treating foreign .node files as PE binaries. + * @param {string} root - Owned, materialized runtime directory. + * @returns {Promise} Sorted real PE files; rejects links and malformed Windows executable files. + */ +export async function windowsRuntimeCode(root) { + const rootStat = await lstat(root) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory') + const files = [] + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name) + if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`) + if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue } + if (!entry.isFile()) continue + const file = await open(path, 'r') + let portableExecutable = false + let windowsCandidate = false + try { + const header = Buffer.alloc(64) + const { bytesRead } = await file.read(header, 0, header.length, 0) + windowsCandidate = bytesRead >= 2 && header.readUInt16LE(0) === 0x5a4d + if (bytesRead === 64 && windowsCandidate) { + const signature = Buffer.alloc(4) + const offset = header.readUInt32LE(0x3c) + const read = await file.read(signature, 0, 4, offset) + portableExecutable = offset >= 64 && read.bytesRead === 4 && signature.readUInt32LE(0) === 0x4550 + } + } finally { await file.close() } + if (portableExecutable) files.push(path) + else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`) + } + return files.sort() +} + +/** Drain each public-key verification batch before signing or reporting a failure. */ +async function inspectWindowsCode(files, inspect) { + if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files) + const signatures = [] + for (let offset = 0; offset < files.length; offset += 4) { + const results = await Promise.allSettled(files.slice(offset, offset + 4).map(path => inspect(path))) + for (const result of results) { + if (result.status === 'rejected') throw result.reason + signatures.push(result.value) + } + } + return signatures +} + +/** + * Preserve valid signatures and sign unsigned PE files with a supervised signer. + * @param {string} root - Owned final runtime directory. + * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. + * @returns {Promise} Resolves only after sequential signatures and verification; no retries. + */ +export async function signWindowsCode(root, options) { + const inspect = options.inspect ?? inspectWindowsRuntimeSignature + const files = await windowsRuntimeCode(root) + if (files.length === 0) throw new Error('Windows code: no Windows code found') + const unsigned = [] + const signatures = await inspectWindowsCode(files, inspect) + for (const [index, path] of files.entries()) { + const signature = signatures[index] + options.record({ type: 'windows-code-signature', path, ...signature }) + if (signature.status === 'NotSigned') unsigned.push(path) + else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) + } + options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) + for (const path of unsigned) { + await options.sign({ path, hash: 'sha256', isNest: false }) + const signature = await inspect(path) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error(`Windows code: signing verification failed: ${path}`) + } + options.record({ type: 'windows-code-signature-verified', path, ...signature }) + } +} + +/** + * Reject any unsigned or invalid PE remaining in a completed directory. + * @param {string} root Materialized artifact directory. + * @param {typeof inspectWindowsRuntimeSignature} inspect Public-key verifier. + * @returns {Promise} Resolves after every discovered PE has a valid signature. + */ +export async function verifyWindowsCode(root, inspect = inspectWindowsRuntimeSignature) { + const files = await windowsRuntimeCode(root) + if (files.length === 0) throw new Error('Windows code: no Windows code found') + const signatures = await inspectWindowsCode(files, inspect) + for (const [index, path] of files.entries()) { + const signature = signatures[index] + if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) + } +} diff --git a/apps/desktop/scripts/windows-sign.cmd b/apps/desktop/scripts/windows-sign.cmd index 366f787f57..8afed8833e 100644 --- a/apps/desktop/scripts/windows-sign.cmd +++ b/apps/desktop/scripts/windows-sign.cmd @@ -13,5 +13,5 @@ set "DSH_DESKTOP_WINDOWS_TOKEN_PIN=" set "DSH_DESKTOP_WINDOWS_KEY_CONTAINER=" set "DSH_DESKTOP_WINDOWS_SIGN_TARGET=" set "DSH_DESKTOP_WINDOWS_SIGN_APPEND=" -set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%" +set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% "%targetFile%" exit /b %errorlevel% diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs index d491196a74..3ebd199615 100644 --- a/apps/desktop/scripts/windows-sign.mjs +++ b/apps/desktop/scripts/windows-sign.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import wineVmModule from 'app-builder-lib/out/vm/WineVm.js' import { beginWindowsSigningAttempt } from './windows-signing-state.mjs' +import { completeWindowsSignature, normalizeWindowsSignature } from './windows-timestamp.mjs' +import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' const execFileAsync = promisify(execFile) const { WineVmManager } = wineVmModule @@ -162,7 +164,7 @@ export function buildWindowsSigningEnvironment(environment, input) { * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise} The signing hook. */ export function createWindowsTokenSigner(options) { - const { path: certificateFile } = resolveCertificateFile(options.certificateFile) + const { path: certificateFile, certificate } = resolveCertificateFile(options.certificateFile) const signTool = resolveSignTool(options.signTool) const { keyContainer, tokenPin } = resolveTokenIdentity(options) const commandInterpreter = options.commandInterpreter @@ -174,43 +176,67 @@ export function createWindowsTokenSigner(options) { if (configuration.hash !== 'sha256') { throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) } + if (configuration.isNest) throw new Error('Windows release signing does not support appended signatures') if (await options.preserveSignature?.(configuration.path)) return - await repairDanglingAuthenticodeDirectory(configuration.path) const secrets = [tokenPin] - const attempt = beginWindowsSigningAttempt({ runDirectory: options.runDirectory, - stateDirectory: options.stateDirectory, target: configuration.path }) - let result + const runDirectory = options.runDirectory ?? process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + if (!runDirectory) throw new Error('Windows hardware signing requires a supervised packaging run') + const { inspectWindowsRuntimeSignature: inspect } = await import('./windows-runtime-signature.mjs') + const thumbprint = certificate.fingerprint.replaceAll(':', '') try { - const operation = execFileAsync(commandInterpreter, [ - '/d', - '/v:off', - '/c', - WINDOWS_SIGN_SCRIPT, - ], { - cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY, - env: buildWindowsSigningEnvironment(process.env, { - certificateFile, - signTool, - path: configuration.path, - isNest: configuration.isNest, - tokenPin, - keyContainer, - }), - windowsHide: true, + await completeWindowsSignature(configuration.path, { + thumbprint, inspect, evidenceDirectory: runDirectory, + record: event => recordPackagingEvent(runDirectory, event), + normalize: path => normalizeWindowsSignature(path, signTool, scrubWindowsSigningEnvironment(process.env)), + timestamp: async path => { await execFileAsync(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', path], { + env: scrubWindowsSigningEnvironment(process.env), windowsHide: true, timeout: 60_000, + }) }, + sign: async (path) => { + await repairDanglingAuthenticodeDirectory(path) + const attempt = beginWindowsSigningAttempt({ runDirectory, + stateDirectory: options.stateDirectory, target: path }) + let result + try { + const operation = execFileAsync(commandInterpreter, [ + '/d', + '/v:off', + '/c', + WINDOWS_SIGN_SCRIPT, + ], { + cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY, + env: buildWindowsSigningEnvironment(process.env, { + certificateFile, + signTool, + path, + isNest: false, + tokenPin, + keyContainer, + }), + windowsHide: true, + }) + attempt.started(operation.child?.pid ?? null) + result = await operation + const signature = await inspect(path) + if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) { + throw new Error('Windows hardware signing: primary signature verification failed') + } + } + catch (error) { + const failure = createRedactedWindowsSigningError(error, path, secrets) + attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message) + throw failure + } + attempt.success() + const stdout = redactedSigningOutput(result.stdout, secrets) + const stderr = redactedSigningOutput(result.stderr, secrets) + if (stdout !== '') process.stdout.write(stdout) + if (stderr !== '') process.stderr.write(stderr) + }, }) - attempt.started(operation.child?.pid ?? null) - result = await operation + } catch (error) { + failPackagingRun(runDirectory, 'signature-completion-failed') + throw error } - catch (error) { - const failure = createRedactedWindowsSigningError(error, configuration.path, secrets) - attempt.failure(typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, failure.message) - throw failure - } - attempt.success() - const stdout = redactedSigningOutput(result.stdout, secrets) - const stderr = redactedSigningOutput(result.stderr, secrets) - if (stdout !== '') process.stdout.write(stdout) - if (stderr !== '') process.stderr.write(stderr) }) return pending } diff --git a/apps/desktop/scripts/windows-signature-batch.d.mts b/apps/desktop/scripts/windows-signature-batch.d.mts new file mode 100644 index 0000000000..a58c652d42 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-batch.d.mts @@ -0,0 +1,17 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' + +/** + * Validate complete, ordered, per-file inspection results. + * @param stdout Newline-delimited signature records. + * @param stderr Process diagnostics, which must be empty. + * @param files Exact requested file order. + * @returns Validated signatures; rejects missing, duplicate, reordered or malformed rows. + */ +export function parseSignatureRows(stdout: string, stderr: string, files: readonly string[]): WindowsRuntimeSignature[] + +/** + * Inspect every file with at most four processes and 32 files per process. + * @param files Materialized PE paths in stable order. + * @returns One signature per file, after all processes in each batch have exited, including on failure. + */ +export function inspectSignaturesBatched(files: readonly string[]): Promise diff --git a/apps/desktop/scripts/windows-signature-batch.mjs b/apps/desktop/scripts/windows-signature-batch.mjs new file mode 100644 index 0000000000..cbc37b9d30 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-batch.mjs @@ -0,0 +1,57 @@ +/** Bounded public-key verification batches; every output row must match its requested file. */ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' + +const command = '$ErrorActionPreference="Stop"; [Console]::InputEncoding=[System.Text.UTF8Encoding]::new(); [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new(); Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1" -ErrorAction Stop; Import-Module "$PSHOME/Modules/Microsoft.PowerShell.Utility/Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop; $files=[Console]::In.ReadToEnd()|ConvertFrom-Json; foreach($file in $files){ $s=Get-AuthenticodeSignature -LiteralPath $file; [pscustomobject]@{path=$file;status=[string]$s.Status;timestamped=($null -ne $s.TimeStamperCertificate);thumbprint=$s.SignerCertificate.Thumbprint}|ConvertTo-Json -Compress }' + +/** + * Reject missing, duplicated, reordered or malformed file results. + * @param {string} stdout Newline-delimited signature records. + * @param {string} stderr Process diagnostics; any content rejects the batch. + * @param {readonly string[]} files Exact requested order. + * @returns {Array<{status: string, timestamped: boolean, thumbprint: string|null}>} Complete signature results. + */ +export function parseSignatureRows(stdout, stderr, files) { + const rows = stdout.trim().split(/\r?\n/u) + if (stderr || rows.length !== files.length) throw new Error('Windows signature batch: incomplete inspection output') + return rows.map((row, index) => { + const value = JSON.parse(row) + if (value === null || typeof value !== 'object' || value.path !== files[index] + || typeof value.status !== 'string' || typeof value.timestamped !== 'boolean' + || !(value.thumbprint === null || typeof value.thumbprint === 'string' && /^[A-F\d]{40}$/iu.test(value.thumbprint))) { + throw new Error(`Windows signature batch: invalid inspection output: ${files[index]}`) + } + return { status: value.status, timestamped: value.timestamped, thumbprint: value.thumbprint } + }) +} + +async function inspectBatch(files) { + const pending = promisify(execFile)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { + env: scrubWindowsSigningEnvironment(process.env), encoding: 'utf8', windowsHide: true, + timeout: 60_000, maxBuffer: 64 * 1024 * files.length, + }) + pending.child.stdin.on('error', () => { /* Process failure is reported by the awaited execFile result. */ }) + pending.child.stdin.end(JSON.stringify(files)) + const { stdout, stderr } = await pending + return parseSignatureRows(stdout, stderr, files) +} + +/** + * Inspect every file through at most four processes, draining failures before returning. + * @param {readonly string[]} files Materialized PE paths in stable order. + * @returns {Promise>} One result per input. + */ +export async function inspectSignaturesBatched(files) { + const signatures = [] + for (let offset = 0; offset < files.length; offset += 128) { + const groups = [] + for (let i = offset; i < Math.min(files.length, offset + 128); i += 32) groups.push(files.slice(i, i + 32)) + const settled = await Promise.allSettled(groups.map(inspectBatch)) + for (const result of settled) { + if (result.status === 'rejected') throw result.reason + signatures.push(...result.value) + } + } + return signatures +} diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts new file mode 100644 index 0000000000..bc45bf1b52 --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -0,0 +1,26 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' +import type { createWindowsTokenSigner } from './windows-sign.mjs' + +/** File-owned cache policy and existing supervised signing operations. */ +export interface WindowsSignatureCacheOptions { + root: string + identity: string + thumbprint: string + sign: ReturnType + inspect: (path: string) => Promise + record: (event: object) => void +} + +/** + * Hash the public certificate and files controlling signing output in fixed order. + * @param files Certificate and signing-toolchain paths, excluding credentials. + * @returns Content identity independent of installation paths and PIN values. + */ +export function signatureCacheIdentity(files: readonly string[]): Promise + +/** + * Reuse complete signed files only after input, integrity, identity and timestamp checks. + * @param options Cache policy, supervised signer, public-key verifier and audit sink. + * @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries. + */ +export function createCachedSigner(options: WindowsSignatureCacheOptions): ReturnType diff --git a/apps/desktop/scripts/windows-signature-cache.mjs b/apps/desktop/scripts/windows-signature-cache.mjs new file mode 100644 index 0000000000..a4302badbe --- /dev/null +++ b/apps/desktop/scripts/windows-signature-cache.mjs @@ -0,0 +1,97 @@ +/** Cache complete signed files; corrupt entries stop the caller before hardware access. */ +import { createHash } from 'node:crypto' +import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve, toNamespacedPath } from 'node:path' + +const digest = bytes => createHash('sha256').update(bytes).digest('hex') + +/** + * Include public certificate and every file that controls signing output in the policy identity. + * @param {readonly string[]} files Certificate, SignTool, signing script and signer implementation paths, in fixed order. + * @returns {Promise} Content identity independent of installation paths and PIN values. + */ +export async function signatureCacheIdentity(files) { + return digest(JSON.stringify(await Promise.all(files.map(async path => digest(await regularFile(path)))))) +} + +async function regularFile(path) { + const stat = await lstat(path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`signature cache: expected regular file: ${path}`) + return readFile(path) +} + +async function directory(path) { + const stat = await lstat(path) + if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== resolve(path)) { + throw new Error(`signature cache: expected unlinked directory: ${path}`) + } +} + +function validSignature(signature, thumbprint) { + return signature.status === 'Valid' && signature.timestamped + && signature.thumbprint?.toUpperCase() === thumbprint.toUpperCase() +} + +/** + * Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy. + * @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations. + * @returns {ReturnType} Serial, fail-stop signing callback; cache hits still verify trust and timestamp. + */ +export function createCachedSigner(options) { + let pending = Promise.resolve() + async function readEntry(entry, key, inputDigest) { + await directory(entry) + const record = JSON.parse((await regularFile(join(entry, 'record.json'))).toString('utf8')) + if (record === null || typeof record !== 'object' || record.version !== 1 || record.key !== key || record.inputDigest !== inputDigest + || record.identity !== options.identity || typeof record.signedDigest !== 'string' + || !/^[a-f\d]{64}$/u.test(record.signedDigest)) throw new Error('signature cache: invalid input record') + const payload = await regularFile(join(entry, 'payload')) + if (digest(payload) !== record.signedDigest) throw new Error('signature cache: corrupt signed payload') + return record + } + async function sign(configuration) { + if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported') + const inputDigest = digest(await regularFile(configuration.path)) + const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity })) + await mkdir(options.root, { recursive: true }) + await directory(options.root) + const entry = join(options.root, key) + let hit = false + try { await lstat(entry); hit = true } + catch (error) { if (error.code !== 'ENOENT') throw error } + if (hit) { + const record = await readEntry(entry, key, inputDigest) + const staging = await mkdtemp(toNamespacedPath(join(dirname(configuration.path), '.signature-restore-'))) + try { + const candidate = join(staging, basename(configuration.path)) + await copyFile(join(entry, 'payload'), candidate) + if (digest(await regularFile(candidate)) !== record.signedDigest) throw new Error('signature cache: restore changed signed bytes') + if (!validSignature(await options.inspect(candidate), options.thumbprint)) throw new Error('signature cache: invalid cached signature') + if (digest(await regularFile(configuration.path)) !== inputDigest) throw new Error('signature cache: target changed before restore') + await rename(candidate, configuration.path) + } finally { await rm(staging, { recursive: true, force: true }) } + options.record({ type: 'signature-cache-hit', key, path: configuration.path }) + return + } + options.record({ type: 'signature-cache-miss', key, path: configuration.path }) + await options.sign(configuration) + if (!validSignature(await options.inspect(configuration.path), options.thumbprint)) throw new Error('signature cache: new signature verification failed') + const staging = await mkdtemp(toNamespacedPath(join(options.root, '.publish-'))) + try { + await copyFile(configuration.path, join(staging, 'payload')) + const signedDigest = digest(await regularFile(join(staging, 'payload'))) + if (signedDigest !== digest(await regularFile(configuration.path))) throw new Error('signature cache: signed file changed during publication') + await writeFile(join(staging, 'record.json'), JSON.stringify({ version: 1, key, inputDigest, identity: options.identity, signedDigest }), { flag: 'wx', flush: true }) + try { await rename(staging, entry) } + catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code)) throw error + await readEntry(entry, key, inputDigest) + } + options.record({ type: 'signature-cache-published', key, path: configuration.path }) + } finally { await rm(staging, { recursive: true, force: true }) } + } + return configuration => { + pending = pending.then(() => sign(configuration)) + return pending + } +} diff --git a/apps/desktop/scripts/windows-signing-preflight.ts b/apps/desktop/scripts/windows-signing-preflight.ts index fd4546c869..6076e4cd4e 100644 --- a/apps/desktop/scripts/windows-signing-preflight.ts +++ b/apps/desktop/scripts/windows-signing-preflight.ts @@ -30,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en /** * Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate. * @param options Supervised evidence directory, file-owned settings and isolated test adapters. - * @returns Resolves after one signature and verification; failures retain evidence and never retry or unlock a token. + * @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing. */ export async function preflightWindowsSigning(options: SigningPreflightOptions): Promise { const { environment, runDirectory } = options diff --git a/apps/desktop/scripts/windows-timestamp.d.mts b/apps/desktop/scripts/windows-timestamp.d.mts new file mode 100644 index 0000000000..bb1dd617c0 --- /dev/null +++ b/apps/desktop/scripts/windows-timestamp.d.mts @@ -0,0 +1,30 @@ +import type { WindowsRuntimeSignature } from './windows-runtime-signature.mjs' + +/** + * Remove unsigned signature attributes on a previously verified private copy. + * @param path Single-signature private copy. + * @param signTool Configured SignTool executable. + * @param environment Credential-free subprocess environment. + * @returns Resolves after removal or an unchanged-file warning; other failures reject. + */ +export function normalizeWindowsSignature(path: string, signTool: string, environment: NodeJS.ProcessEnv): Promise + +/** Public operations on isolated files; sign resolves only after verified hardware success. */ +export interface WindowsTimestampOptions { + sign: (path: string) => Promise + timestamp: (path: string) => Promise + normalize: (path: string) => Promise + inspect: (path: string) => Promise + thumbprint: string + evidenceDirectory: string + record: (event: object) => void + wait?: (milliseconds: number) => Promise +} + +/** + * Complete a single verified hardware signature using bounded, isolated timestamp attempts. + * @param path Exclusively owned build file in trusted storage. + * @param options Verified signer, public operations, certificate and retained audit storage. + * @returns Resolves after a valid timestamp and exact normalized-content verification; failures preserve evidence. + */ +export function completeWindowsSignature(path: string, options: WindowsTimestampOptions): Promise diff --git a/apps/desktop/scripts/windows-timestamp.mjs b/apps/desktop/scripts/windows-timestamp.mjs new file mode 100644 index 0000000000..bb25e2d80f --- /dev/null +++ b/apps/desktop/scripts/windows-timestamp.mjs @@ -0,0 +1,107 @@ +/** Finish timestamps on isolated copies without repeating a verified private-key operation. */ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { copyFile, cp, lstat, mkdtemp, readFile, rename, rm } from 'node:fs/promises' +import { dirname, extname, join, toNamespacedPath } from 'node:path' +import { tmpdir } from 'node:os' +import { setTimeout } from 'node:timers/promises' + +async function digest(path) { + const stat = await lstat(path) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Windows timestamp: expected a regular file') + return createHash('sha256').update(await readFile(path)).digest('hex') +} + +/** + * Remove unsigned signature attributes on a private copy using the configured SignTool. + * @param {string} path Previously verified, single-signature private copy. + * @param {string} signTool Configured SignTool executable. + * @param {NodeJS.ProcessEnv} environment Credential-free subprocess environment. + * @returns {Promise} Resolves after removal, or a warning that left all bytes unchanged. + */ +export async function normalizeWindowsSignature(path, signTool, environment) { + const before = await digest(path) + try { + await promisify(execFile)(signTool, ['remove', '/u', path], { env: environment, windowsHide: true, timeout: 60_000 }) + } catch (error) { + // SignTool returns warning status when a verified signature has no unsigned attributes. + if (error.code !== 2 || error.signal || error.killed || await digest(path) !== before) throw error + } +} + +/** + * Sign once, timestamp fresh copies at most three times, then atomically replace the unchanged input. + * @param {string} path Exclusively owned build file; storage must be trusted. + * @param {import('./windows-timestamp.mjs').WindowsTimestampOptions} options Verified hardware signer and credential-free public operations. + * @returns {Promise} Resolves only after complete signature, exact normalized bytes and committed-byte verification. + */ +export async function completeWindowsSignature(path, options) { + const original = await digest(path) + const staging = await mkdtemp(join(tmpdir(), 'dsh-sign-')) + const extension = extname(path) + let publication + let completed = false + let retained = false + try { + const raw = join(staging, `raw${extension}`) + const normalized = join(staging, `normalized${extension}`) + // SignTool does not consistently support extended-length Windows paths. + if (normalized.length >= 260) throw new Error('Windows timestamp: temporary SignTool path exceeds MAX_PATH') + await copyFile(path, raw) + if (await digest(raw) !== original) throw new Error('Windows timestamp: input changed during staging') + options.record({ type: 'signature-staging', path, stagedPath: raw }) + await options.sign(raw) + const frozen = await digest(raw) + await copyFile(raw, normalized) + await options.normalize(normalized) + const baseline = await digest(normalized) + for (let attempt = 1; attempt <= 3; attempt++) { + if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed') + const candidate = join(staging, `attempt-${attempt}${extension}`) + await copyFile(raw, candidate) + if (await digest(candidate) !== frozen) throw new Error('Windows timestamp: copy changed') + options.record({ type: 'timestamp-start', path, attempt }) + try { await options.timestamp(candidate) } + catch (error) { + options.record({ type: 'timestamp-failure', path, attempt, code: typeof error.code === 'number' ? error.code : null }) + if (await digest(raw) !== frozen) throw new Error('Windows timestamp: signed input changed after failure') + if (attempt === 3 || ![1, 2].includes(error.code) || error.signal || error.killed) throw error + await (options.wait ?? setTimeout)(attempt * 1000) + continue + } + const signature = await options.inspect(candidate) + if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { + throw new Error('Windows timestamp: completed signature verification failed') + } + await copyFile(candidate, normalized) + await options.normalize(normalized) + if (await digest(normalized) !== baseline) throw new Error('Windows timestamp: signing content changed') + if (await digest(raw) !== frozen || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication') + const expected = await digest(candidate) + publication = await mkdtemp(toNamespacedPath(join(dirname(path), '.complete-signature-'))) + const ready = join(publication, `ready${extension}`) + await copyFile(candidate, ready) + if (await digest(ready) !== expected || await digest(path) !== original) throw new Error('Windows timestamp: input changed before publication') + await rename(ready, path) + if (await digest(path) !== expected) throw new Error('Windows timestamp: committed bytes changed') + options.record({ type: 'timestamp-success', path, attempt }) + completed = true + return + } + } catch (error) { + try { + const evidence = await mkdtemp(join(options.evidenceDirectory, 'signature-failure-')) + await cp(staging, join(evidence, 'files'), { recursive: true, errorOnExist: true, force: false }) + options.record({ type: 'signature-failure-evidence', path, directory: evidence }) + retained = true + } catch (evidenceError) { + throw new AggregateError([error, evidenceError], 'Windows signature failed and its evidence could not be retained') + } + throw error + } finally { + if (publication) await rm(publication, { recursive: true, force: true }) + // Failure staging remains available if evidence publication or its audit record fails. + if (completed || retained) await rm(staging, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d5bfb1e0b3..5c21833b0e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -397,6 +397,9 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) + // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. + // Removing this route leaves those modal windows empty while their parent remains blocked. + if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') || ['/favicon.svg', '/manifest.webmanifest'].includes(url.pathname)) { @@ -707,7 +710,7 @@ async function main(): Promise { if (quitting) return event.preventDefault() quitting = true - mainWindow?.hide() + if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.hide() updateSchedule.dispose() updateDialog.dispose() mandatoryUI?.dispose() diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 2705eeb3fb..a3ca6bcf8f 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -43,6 +43,7 @@ export interface MandatoryUpdateWindowOptions { readonly install: (version: string) => Promise } +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/mandatory-update.html' /** A modal child blocks the product window without cancelling work in the Host. */ diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index 6ba2fc269a..f48f70d075 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -30,6 +30,7 @@ export interface UpdateDialogApi { respond(index: number): Promise } +// main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/update-dialog.html' /** One replaceable confirmation window; aborted checks and mandatory policy cancel ordinary prompts. */ diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs index a069118006..41f6b4223b 100644 --- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -15,14 +15,16 @@ const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), ' assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the Electron Node runtime version') assert.equal(process.platform, descriptor.platform) assert.equal(process.arch, descriptor.arch) +const resourcesRuntime = process.argv[3] ?? join(dirname(root), 'runtime') +const { verifyDesktopRuntime } = await import('../../lib/types/runtime-tree.js') +await verifyDesktopRuntime(root, descriptor.release.version, { platform: process.platform, arch: process.arch }) const requireRuntime = createRequire(join(root, 'package.json')) const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) /** Run a package script with only the shipped node launcher available on PATH. */ function checkPnpm() { - const resources = dirname(root) - const bin = join(resources, 'runtime', 'bin') - const pnpm = join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') + const bin = join(resourcesRuntime, 'bin') + const pnpm = join(resourcesRuntime, 'pnpm', 'bin', 'pnpm.mjs') writeFileSync(join(scratch, 'package.json'), JSON.stringify({ name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' }, })) @@ -54,7 +56,7 @@ async function checkPty() { ))) Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }) env.DSH_DESKTOP_NODE_EXECUTABLE = process.execPath - env.PATH = `${join(dirname(root), 'runtime', 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}` + env.PATH = `${join(resourcesRuntime, 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}` // A Windows GUI executable needs a console-owning shell when launched inside ConPTY. const executable = process.platform === 'win32' ? process.env.ComSpec : process.execPath const args = process.platform === 'win32' ? ['/d', '/c', 'node', script] : [script] @@ -137,6 +139,8 @@ function checkHtml() { } try { + const builtin = requireRuntime('node-addon-require-builtin') + assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function') checkPnpm() checkKoffi() await checkSharp() diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 246dcbcfdf..3df37cb4ae 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -145,6 +145,7 @@ const harness = await vi.hoisted(async () => { set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value }, dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() }, openExternal: vi.fn(), + protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise) => void>(), applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), disableAllPlugins: vi.fn(async () => { pluginsEnabled = false @@ -207,7 +208,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, - protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -307,6 +308,19 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('serves shell dialogs and their assets without forwarding them to the Host', async () => { + await readyForUpdate() + const { serveWebDocument, forwardWebRequest } = await import('../src/web-document.ts') + const handler = harness.protocolHandle.mock.calls[0]![1] + for (const file of ['update-dialog.html', 'update-dialog.js', 'update-dialog.css', 'update-close.svg', 'mandatory-update.html']) { + const request = new Request(`dsh-app://shell/${file}`) + await handler(request) + expect(serveWebDocument).toHaveBeenLastCalledWith(request, join('desktop-test-app', 'renderer')) + } + expect(forwardWebRequest).not.toHaveBeenCalled() + expect((await handler(new Request('dsh-app://unknown/update-dialog.html'))).status).toBe(404) + }) + it.each([ ['darwin', true, 'en-US'], ['darwin', false, 'zh-CN'], @@ -717,6 +731,19 @@ describe('desktop main startup', () => { await harness.quitCompleted.promise }) + it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => { + const host = await readyForUpdate() + const window = harness.windows[0]! + window.destroyed = true + window.hide.mockImplementation(() => { throw new TypeError('Object has been destroyed') }) + expect(() => { harness.app.quit() }).not.toThrow() + expect(window.hide).not.toHaveBeenCalled() + await host.stopping.promise + window.emit('closed') + host.exited.resolve() + await harness.quitCompleted.promise + }) + it('shares the failed-check deadline across focus and resume, while explicit checks reset polling', async () => { vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS', '1000') vi.stubEnv('DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS', '4000') diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 309c446909..6a238f1026 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -31,10 +31,18 @@ it('requires one signing preflight before building, then records only the comple expect(stages.filter(stage => stage === 'preflight:windows-signing')).toHaveLength(1) expect(run.run.mock.calls[0]![3]).toMatchObject({ env: { DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' }, timeoutMs: 60_000 }) expect(run.run.mock.calls[1]![3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(stages.indexOf('run sign:primary-runtime --dsh')).toBeGreaterThan(stages.indexOf('run prepare:dsh --defer-runtime-smoke')) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts') + for (const call of run.run.mock.calls) { + if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) { + expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + } + } expect(writeFileSync).toHaveBeenCalledOnce() }) -it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh', +it.each(['preflight:windows-signing', 'run build:official', 'run sign:primary-runtime', 'run prepare:dsh --defer-runtime-smoke', 'run sign:primary-runtime --dsh', + 'exec tsx scripts/smoke-packaged-runtime.ts', 'exec electron-builder --config electron-builder.config.mjs --win --x64 --publish never']) ('never continues or records a release after %s fails', async (failure) => { const { run, stages } = supervisor(failure) @@ -50,6 +58,7 @@ it.each(['--unsigned', '--prepare-only'])('keeps %s hardware-free and creates no expect(stages[0]).toBe('run build:official') expect(stages).not.toContain('preflight:windows-signing') expect(stages).not.toContain('run sign:primary-runtime') + expect(stages).not.toContain('run sign:primary-runtime --dsh') for (const call of run.run.mock.calls) expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') expect(writeFileSync).not.toHaveBeenCalled() }) diff --git a/apps/desktop/tests/prepared-runtime-smoke.spec.ts b/apps/desktop/tests/prepared-runtime-smoke.spec.ts new file mode 100644 index 0000000000..2a2644719e --- /dev/null +++ b/apps/desktop/tests/prepared-runtime-smoke.spec.ts @@ -0,0 +1,44 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { smokePreparedRuntime } from '../scripts/smoke-prepared-runtime.ts' +import { smokeDesktopRuntime } from '../scripts/smoke-runtime.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const { payload } = vi.hoisted(() => ({ payload: vi.fn(async (..._args: unknown[]) => ({ stdout: '' })) })) +vi.mock('node:child_process', async (importOriginal) => { + const { promisify } = await import('node:util') + return { ...await importOriginal(), + execFile: Object.assign(vi.fn(), { [promisify.custom]: payload }) } +}) +vi.mock('../scripts/smoke-runtime.ts', () => ({ smokeDesktopRuntime: vi.fn(async () => {}) })) + +const roots: string[] = [] +const hostArch = Object.getOwnPropertyDescriptor(process, 'arch')! +afterEach(() => { + Object.defineProperty(process, 'arch', hostArch) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => { + const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-target-')) + roots.push(root) + const fixture = runtimeFixture(root) + const target = { platform: 'darwin' as const, arch: 'x64' } + writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target) + Object.defineProperty(process, 'arch', { ...hostArch, value: 'arm64' }) + await expect(verifyDesktopRuntime(root, fixture.release.version, { ...target, arch: process.arch })) + .rejects.toThrow(/incompatible/u) + const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target) + const electron = join(root, 'target-electron') + const resources = join(root, 'runtime') + await smokePreparedRuntime(root, electron, resources, descriptor) + expect(payload.mock.calls[0]![0]).toBe(electron) + expect(payload.mock.calls[0]![1]).toEqual(expect.arrayContaining([root, resources])) + expect(smokeDesktopRuntime).toHaveBeenCalledWith(root, electron, descriptor, expect.any(Object), resources) + const environment = vi.mocked(smokeDesktopRuntime).mock.calls[0]![3] + expect(existsSync(environment.NARB_NATIVE_CACHE_DIR!)).toBe(false) +}) diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 56381f4aa6..242c93f339 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -2,8 +2,10 @@ import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { signWindowsPrimaryRuntime, windowsRuntimeCode } from '../scripts/sign-primary-runtime.ts' -import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs' +import { signWindowsPrimaryRuntime, signWindowsDesktopRuntime } from '../scripts/sign-primary-runtime.ts' +import { inspectWindowsRuntimeSignature, preserveWindowsRuntimeSignature, windowsRuntimeCode, verifyWindowsCode, signWindowsCode } from '../scripts/windows-runtime-signature.mjs' +import { runtimeFixture } from './runtime-fixture.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' import { createPackagingRun } from '../scripts/packaging-run.mjs' const roots: string[] = [] @@ -25,11 +27,11 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) it('selects real PE code, including .node, without signing foreign native modules or data', async () => { - const root = await fixture(['runtime.node', 'python.exe']) + const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary']) await mkdir(join(root, 'nested')) await writeFile(join(root, 'nested', 'foreign.node'), Buffer.from([0x7f, 0x45, 0x4c, 0x46])) await writeFile(join(root, 'readme.txt'), 'text') - expect(await windowsRuntimeCode(root)).toEqual([join(root, 'python.exe'), join(root, 'runtime.node')].sort()) + expect(await windowsRuntimeCode(root)).toEqual(['python.exe', 'runtime.node', 'extensionless', 'custom.binary'].map(name => join(root, name)).sort()) }) it('refuses malformed executables and root or nested directory links', async () => { @@ -150,3 +152,60 @@ it('preserves only identical, valid runtime copies and records verification with await writeFile(join(sourceRoot, 'linked', 'python.exe'), await readFile(join(sourceRoot, 'python.exe'))) await expect(preserveWindowsRuntimeSignature(join(destinationRoot, 'linked', 'python.exe'), options)).rejects.toThrow('linked copy') }) + +it('rejects a newly added unsigned dependency in the final artifact audit', async () => { + const root = await fixture(['new-dependency.node', 'vendor.dll']) + await expect(verifyWindowsCode(root, async path => path.endsWith('.node') ? unsigned : valid)).rejects.toThrow('NotSigned') + await expect(verifyWindowsCode(root, async () => valid)).resolves.toBeUndefined() +}) + +it('records signed dependency bytes before smoke and rejects changes after smoke', async () => { + const root = await fixture(['dependency.node']) + runtimeFixture(root) + let signed = false + const options = { thumbprint, record: () => {}, + inspect: async () => signed ? valid : unsigned, + sign: async ({ path }: { path: string }) => { + await writeFile(path, Buffer.concat([await readFile(path), Buffer.from('signature')])) + signed = true + }, + smoke: vi.fn(async () => { await verifyDesktopRuntime(root, '1.0.0') }), + } + await signWindowsDesktopRuntime(root, '1.0.0', options) + expect(options.smoke).toHaveBeenCalledOnce() + await expect(signWindowsDesktopRuntime(root, '1.0.0', { ...options, + smoke: async () => { await writeFile(join(root, 'dependency.node'), 'mutated') }, + })).rejects.toThrow('integrity') +}) + +it('bounds public-key inspection to four files and drains a failed batch before returning', async () => { + const root = await fixture(['a.node', 'b.node', 'c.node', 'd.node', 'e.node']) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + let active = 0 + let maximum = 0 + const calls: string[] = [] + const inspect = async (path: string) => { + calls.push(path) + active += 1 + maximum = Math.max(maximum, active) + if (active === 4) started.resolve(undefined) + try { + await finish.promise + if (path.endsWith('a.node')) throw new Error('inspection failed') + return unsigned + } finally { active -= 1 } + } + const sign = vi.fn() + const pending = signWindowsCode(root, { thumbprint, inspect, sign, record: () => {} }) + const rejected = expect(pending).rejects.toThrow('inspection failed') + try { + await started.promise + expect(calls).toHaveLength(4) + expect(sign).not.toHaveBeenCalled() + } finally { finish.resolve(undefined); await rejected } + expect(maximum).toBe(4) + expect(active).toBe(0) + expect(calls).toHaveLength(4) + expect(sign).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/runtime-manifests.spec.ts b/apps/desktop/tests/runtime-manifests.spec.ts new file mode 100644 index 0000000000..beb778c0df --- /dev/null +++ b/apps/desktop/tests/runtime-manifests.spec.ts @@ -0,0 +1,27 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { prepareRuntimeManifests } from '../scripts/prepare-runtime-manifests.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +it('seals dependency metadata that remains unchanged by archive preparation', async () => { + const root = mkdtempSync(join(tmpdir(), 'runtime-manifests-')) + const fixture = runtimeFixture(root) + try { + const directory = join(root, 'node_modules/example/node_modules/nested') + mkdirSync(directory, { recursive: true }) + const manifest = join(directory, 'package.json') + writeFileSync(manifest, JSON.stringify({ name: 'nested', version: '1.0.0', + scripts: { test: 'vitest' }, keywords: ['example'], bugs: 'https://example.com', + main: 'index.js', dependencies: { runtime: '1.0.0' } })) + await expect(verifyDesktopRuntime(root, fixture.release.version)).rejects.toThrow('integrity') + await prepareRuntimeManifests(root) + expect(JSON.parse(readFileSync(manifest, 'utf8'))).toEqual({ name: 'nested', version: '1.0.0', + main: 'index.js', dependencies: { runtime: '1.0.0' } }) + writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name)) + await prepareRuntimeManifests(root) + await verifyDesktopRuntime(root, fixture.release.version) + } finally { rmSync(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts new file mode 100644 index 0000000000..fc45a38ef9 --- /dev/null +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -0,0 +1,185 @@ +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Stats } from 'node:fs' +import { Arch, Packager, type BeforePackContext, type Configuration } from 'app-builder-lib' +import { WinPackager } from 'app-builder-lib/out/winPackager.js' +import { getFileMatchers } from 'app-builder-lib/out/fileMatcher.js' +import { readAsar } from 'app-builder-lib/out/asar/asar.js' +import { afterEach, expect, it, vi } from 'vitest' +import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from '../scripts/windows-asar-unpack.mjs' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +vi.mock('../scripts/windows-sign.mjs', async importOriginal => ({ + ...await importOriginal(), + createWindowsTokenSigner: () => async () => {}, + installWindowsNsisBootstrapSigner: () => {}, + resolveWindowsUpdatePublisher: () => 'Fixture Publisher', +})) +vi.mock('../scripts/windows-runtime-signature.mjs', async importOriginal => ({ + ...await importOriginal(), + signWindowsCode: async () => {}, +})) +// Vite's root-relative IDs also resolve mocked build outputs on a clean checkout. +vi.mock('/apps/desktop/lib/types/runtime-tree.js', () => ({ verifyDesktopRuntime: async () => {} })) +vi.mock('/apps/desktop/lib/types/mandatory-update-policy.js', async () => import('../src/mandatory-update-policy.ts')) +vi.mock('node:crypto', async importOriginal => ({ + ...await importOriginal(), + X509Certificate: class { fingerprint = 'AA:BB' }, +})) + +const require = createRequire(import.meta.url) +// The locked builder omits declarations for its AsarPackager implementation. +const { AsarPackager } = require('app-builder-lib/out/asar/asarUtil.js') as { + AsarPackager: new (packager: object, configuration: object) => { + pack(files: { src: string; destination: string; files: string[]; metadata: Map }[]): Promise + } +} +const roots: string[] = [] +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +async function fixture(external: boolean) { + const root = await mkdtemp(join(tmpdir(), 'windows-asar-')) + roots.push(root) + const appDir = join(root, 'app') + const source = external ? join(root, 'private', 'dsh') : join(appDir, '.desktop-build', 'dsh') + const resources = join(root, 'output', 'resources') + await mkdir(resources, { recursive: true }) + await mkdir(appDir, { recursive: true }) + const pe = Buffer.alloc(128) + pe.writeUInt16LE(0x5a4d, 0) + pe.writeUInt32LE(64, 0x3c) + pe.writeUInt32LE(0x4550, 64) + const names = ['extensionless', 'custom.binary', 'native.node', 'a[0]{x,y}+(z)@!#.binary', '${arch}.binary'] + for (const name of names) { + const file = join(source, 'node_modules', 'foo', name) + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, pe) + } + await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}') + await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor') + const config = { + files: [{ from: source, to: 'dsh', filter: ['**/*'] }, + { from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }], + asarUnpack: ['**/*.node'], + } satisfies Configuration + const info = new Packager({ projectDir: appDir }) + Object.defineProperties(info, { + appDir: { value: appDir }, config: { value: config }, + metadata: { value: { name: 'fixture', version: '1.0.0', description: 'Fixture' } }, + framework: { value: { name: 'electron' } }, + }) + vi.spyOn(info, 'disposeOnBuildFinish').mockImplementation((cleanup) => { cleanups.push(cleanup) }) + const context: BeforePackContext = { packager: new WinPackager(info), arch: Arch.x64, + appOutDir: dirname(resources), outDir: dirname(resources), electronPlatformName: 'win32', targets: [] } + return { root, appDir, source, resources, config, context, names } +} + +async function packageFixture(input: Awaited>) { + const destination = join(input.resources, 'app') + const matchers = getFileMatchers(input.config, 'asarUnpack', destination, { + defaultSrc: input.appDir, globalOutDir: dirname(input.resources), customBuildOptions: {}, + macroExpander: value => value.replaceAll('${arch}', 'x64'), + })! + const mapping = input.config.files[0]! + if (typeof mapping === 'string' || mapping.from === undefined) throw new Error('missing source mapping') + const files: string[] = [] + const metadata = new Map() + async function visit(directory: string): Promise { + for (const name of await readdir(directory)) { + const file = join(directory, name) + const stat = await lstat(file) + if (stat.isDirectory()) await visit(file) + else { files.push(file); metadata.set(file, stat) } + } + } + await visit(mapping.from) + await new AsarPackager({ info: { getWorkspaceRoot: async () => input.root } }, { + defaultDestination: destination, resourcePath: input.resources, options: {}, unpackPattern: matchers[0]!.createFilter(), + }).pack([{ src: mapping.from, destination: join(destination, 'dsh'), files, metadata }]) +} + +it.each([false, true])('unpacks PE files through builder source patterns (external=%s)', async (external) => { + const input = await fixture(external) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + await packageFixture(input) + await verifyWindowsAsarUnpack(input.source, input.resources, files) + const archive = await readAsar(join(input.resources, 'app.asar')) + expect(archive.getFile(join('dsh', 'node_modules', 'foo', 'companion.json')).unpacked).not.toBe(true) + expect(archive.getFile(join('dsh', 'node_modules', 'foo', '$xarchy.binary')).unpacked).toBe(true) + expect(files).toEqual(input.names.map(name => join('node_modules', 'foo', name)).sort()) + const first = input.config.files[0]! + const nested = input.config.files[1]! + if (typeof first === 'string' || typeof nested === 'string') throw new Error('missing source mappings') + expect(first.from === input.source).toBe(!external) + expect(nested.from).toBe(join(first.from, 'node_modules')) + expect(cleanups.length).toBe(external ? 1 : 0) + if (external) { + await cleanups.pop()!() + await expect(lstat(first.from)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(input.source, files[0]!))).toHaveLength(128) + } +}) + +it('rejects an inline PE even when a neighboring unpacked copy exists', async () => { + const input = await fixture(false) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + input.config.asarUnpack = ['**/*.node'] + await packageFixture(input) + const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, await readFile(join(input.source, files[0]!))) + await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow('PE must be unpacked') +}) + +it.each(['changed', 'missing'] as const)('rejects %s unpacked PE bytes', async (state) => { + const input = await fixture(false) + const files = await prepareWindowsAsarUnpack(input.context, input.source) + await packageFixture(input) + const path = join(input.resources, 'app.asar.unpacked', 'dsh', files[0]!) + if (state === 'changed') await writeFile(path, 'changed') + else await rm(path) + await expect(verifyWindowsAsarUnpack(input.source, input.resources, files)).rejects.toThrow(state === 'changed' ? 'PE bytes changed' : 'ENOENT') +}) + +it('registers external staging cleanup before a configuration rewrite fails', async () => { + const input = await fixture(true) + const files = input.config.files + // The source still passes PE discovery; a destination mapping setter fails after staging is copied. + Object.defineProperty(input.config, 'files', { get: () => files, set: () => { throw new Error('configuration rejected') } }) + await expect(prepareWindowsAsarUnpack(input.context, input.source)).rejects.toThrow('configuration rejected') + const staging = await readdir(join(input.appDir, '.desktop-build')) + expect(staging).toHaveLength(1) + expect(cleanups).toHaveLength(1) + await cleanups.pop()!() + expect(await readdir(join(input.appDir, '.desktop-build'))).toEqual([]) +}) + +it.each([true, false])('validates the real builder hook for unsigned=%s', async (unsigned) => { + const input = await fixture(true) + const certificate = join(input.root, 'certificate.cer') + await writeFile(certificate, 'fixture public certificate') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0', + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + }, 'win32', 'x64', input.source) + // Qualification replaces files after creating the base configuration; builder owns a separate config object. + input.config.asarUnpack = [...config.asarUnpack] + await config.beforePack(input.context) + await packageFixture(input) + await config.afterPack(input.context) + if (!unsigned) await config.afterSign(input.context) + const file = join(input.resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'foo', 'custom.binary') + await writeFile(file, 'changed after packaging') + if (unsigned) await expect(config.afterPack(input.context)).rejects.toThrow('PE bytes changed') + else { + await config.afterPack(input.context) + await expect(config.afterSign(input.context)).rejects.toThrow('PE bytes changed') + } +}) diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index 367d418d42..8af2036a5f 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -16,13 +17,23 @@ import { vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } + const { promisify } = await import('node:util') + const mock = vi.fn() + // Node's execFile promisifier returns both output streams; generic promisify drops stderr. + Object.defineProperty(mock, promisify.custom, { value: (...args: unknown[]) => new Promise((resolve, reject) => { + mock(...args, (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(error) + else resolve({ stdout, stderr }) + }) + }) }) + return { ...actual, execFile: mock } }) vi.mock('node:crypto', async importOriginal => ({ ...await importOriginal(), X509Certificate: class { readonly ca = false + readonly fingerprint = 'A'.repeat(40) readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] constructor(contents: Buffer) { @@ -37,6 +48,84 @@ const CERTIFICATE_FILE = 'C:\\release\\server.cer' const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') describe('Windows token signing', () => { + it.each(['invalid-primary', 'timestamp-exhausted', 'timestamp-recovered', 'normalization-corrupt'] as const)( + 'keeps hardware protection separate from %s', async (mode) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-sign-completion-')) + const certificateFile = join(root, 'server.cer') + const signTool = join(root, 'signtool.exe') + const path = join(root, 'application.exe') + const stateDirectory = join(root, 'state') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + await writeFile(path, 'input') + const run = createPackagingRun(join(root, 'records'), {}) + let hardware = 0 + let timestamps = 0 + vi.mocked(execFile).mockImplementation((...args: unknown[]) => { + const command = args[0] as string + const argv = args[1] as string[] + const options = args[2] as { env: NodeJS.ProcessEnv } + const callback = args.at(-1) as (error: Error | null, stdout?: string, stderr?: string) => void + if (argv[0] === '/d') { + hardware++ + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true) + writeFileSync(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!, 'signed:input') + callback(null, '', '') + } else { + expect(options.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(options.env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER).toBeUndefined() + if (command === 'powershell.exe') { + const timestamped = readFileSync(options.env.DSH_RUNTIME_VERIFY_FILE!, 'utf8').endsWith(':timestamp') + if (!timestamped) expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(true) + callback(null, JSON.stringify({ status: mode === 'invalid-primary' ? 'NotTrusted' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) }), '') + } else if (argv[0] === 'remove') { + const target = argv.at(-1)! + const content = readFileSync(target, 'utf8') + if (mode === 'normalization-corrupt') writeFileSync(target, 'unexpected modification') + else writeFileSync(target, content.replace(':timestamp', '')) + if (mode === 'normalization-corrupt' || !content.endsWith(':timestamp')) { + callback(Object.assign(new Error('normalization warning'), { code: 2 })) + } else callback(null, '', '') + } else { + expect(argv[0]).toBe('timestamp') + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(false) + const target = argv.at(-1)! + expect(readFileSync(target, 'utf8')).toBe('signed:input') + timestamps++ + if (mode === 'timestamp-exhausted' || timestamps === 1) { + writeFileSync(target, 'failed partial output') + callback(Object.assign(new Error('timestamp unavailable'), { code: 1, stderr: 'timestamp unavailable' })) + } else { + writeFileSync(target, 'signed:input:timestamp') + callback(null, '', '') + } + } + } + return undefined as unknown as ReturnType + }) + try { + const sign = createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container', + runDirectory: run.directory, stateDirectory }) + const task = { path, hash: 'sha256', isNest: false } + if (mode === 'timestamp-recovered') { + await sign(task) + expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp') + expect(timestamps).toBe(2) + } else { + const results = await Promise.allSettled([sign(task), sign(task)]) + expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']) + expect(await readFile(path, 'utf8')).toBe('input') + expect(timestamps).toBe(mode === 'timestamp-exhausted' ? 3 : 0) + expect(existsSync(join(run.directory, 'fatal.json'))).toBe(true) + } + expect(hardware).toBe(1) + expect(existsSync(join(stateDirectory, 'attempt.json'))).toBe(mode === 'invalid-primary') + } finally { + vi.mocked(execFile).mockReset() + await rm(root, { recursive: true, force: true }) + } + }, 15_000, + ) it('preserves verified copies without hardware and rejects the entire queue after preservation failure', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-copy-signature-')) try { @@ -147,7 +236,7 @@ describe('Windows token signing', () => { expect(text).toContain('setlocal DisableDelayedExpansion\r\n') expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n') expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n') - expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n') + expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% "%targetFile%"\r\n') }) it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => { @@ -197,6 +286,9 @@ describe('Windows token signing', () => { hash: 'sha1', isNest: false, })).rejects.toThrow(/requires SHA-256/u) + await expect(createWindowsTokenSigner({ certificateFile, signTool, tokenPin: 'fixture-pin', keyContainer: 'fixture-container' })({ + path: 'C:\\release\\setup.exe', hash: 'sha256', isNest: true, + })).rejects.toThrow('does not support appended signatures') } finally { await rm(directory, { recursive: true }) diff --git a/apps/desktop/tests/windows-signature-batch.spec.ts b/apps/desktop/tests/windows-signature-batch.spec.ts new file mode 100644 index 0000000000..6e6ebe602f --- /dev/null +++ b/apps/desktop/tests/windows-signature-batch.spec.ts @@ -0,0 +1,50 @@ +/** Parser fault checks; real-file equivalence is measured separately. */ +import { it as test } from 'vitest' +import assert from 'node:assert/strict' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseSignatureRows, inspectSignaturesBatched } from '../scripts/windows-signature-batch.mjs' +import { inspectWindowsRuntimeSignature, signWindowsCode, verifyWindowsCode } from '../scripts/windows-runtime-signature.mjs' + +const files = ['one.node', 'two.dll'] +const rows = files.map(path => ({ path, status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) })) +const output = (input: readonly unknown[]): string => input.map(value => JSON.stringify(value)).join('\n') + +test('each row retains its own signature status', () => { + const input = [rows[0], { ...rows[1], status: 'NotSigned', timestamped: false, thumbprint: null }] + const result = parseSignatureRows(output(input), '', files) + assert.deepEqual(result.map(signature => signature.status), ['Valid', 'NotSigned']) +}) + +for (const [name, input] of [ + ['missing', [rows[0]]], ['duplicate', [rows[0], rows[0]]], ['reordered', [...rows].reverse()], + ['extra', [...rows, rows[0]]], ['null', [rows[0], null]], + ['bad timestamp', [rows[0], { ...rows[1], timestamped: 'true' }]], + ['bad certificate', [rows[0], { ...rows[1], thumbprint: 'wrong' }]], +] as const) test(`${name} output is rejected`, () => { + assert.throws(() => parseSignatureRows(output(input), '', files)) +}) + +test('stderr rejects otherwise complete output', () => { + assert.throws(() => parseSignatureRows(output(rows), 'warning', files)) +}) +test('empty input launches no verifier and returns no rows', async () => { + assert.deepEqual(await inspectSignaturesBatched([]), []) +}) + +test.skipIf(process.platform !== 'win32')('Unicode paths retain the same Windows trust result as individual inspection', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'signature-batch-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, "验证 ' 文件.exe") + await copyFile(join(process.env.SystemRoot!, 'System32', 'cmd.exe'), path) + const expected = await inspectWindowsRuntimeSignature(path) + assert.equal(expected.status, 'Valid') + assert.deepEqual(await inspectSignaturesBatched([path]), [expected]) + await signWindowsCode(root, { + thumbprint: 'A'.repeat(40), + sign: async () => { throw new Error('vendor-signed files must not access signing hardware') }, + record: () => {}, + }) + await verifyWindowsCode(root) +}) diff --git a/apps/desktop/tests/windows-signature-cache.spec.ts b/apps/desktop/tests/windows-signature-cache.spec.ts new file mode 100644 index 0000000000..d1794a66e0 --- /dev/null +++ b/apps/desktop/tests/windows-signature-cache.spec.ts @@ -0,0 +1,168 @@ +/** Fault checks for the exploratory cache; these never call signing hardware. */ +import { it as test, type TestContext } from 'vitest' +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile, mkdir, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import { createCachedSigner, signatureCacheIdentity } from '../scripts/windows-signature-cache.mjs' + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(import.meta.dirname, 'cache-test-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'module.node') + await writeFile(path, 'original') + const cache = join(root, 'cache') + const state = { hardwareCalls: 0, events: [] as object[] } + const thumbprint = 'A'.repeat(40) + const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = { + root: cache, identity: 'test-policy', thumbprint, + sign: async ({ path }) => { state.hardwareCalls++; await writeFile(path, `signed:${await readFile(path, 'utf8')}`) }, + inspect: async path => ({ status: (await readFile(path, 'utf8')).startsWith('signed:') ? 'Valid' : 'NotSigned', timestamped: true, thumbprint }), + record: event => state.events.push(event), + } + const request = { path, hash: 'sha256', isNest: false } + return { root, path, cache, state, options, request } +} + +test('unchanged bytes restored in another file avoid another hardware call', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const second = join(f.root, 'other.node') + await writeFile(second, 'original') + await createCachedSigner(f.options)({ ...f.request, path: second }) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(second, 'utf8'), 'signed:original') + assert.equal(f.state.events.filter(e => 'type' in e && e.type === 'signature-cache-hit').length, 1) +}) + +test('restores cached signatures into deeply nested dependency paths', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const directory = join(f.root, 'dependency'.repeat(12)) + await mkdir(directory) + const path = join(directory, 'module.node') + await writeFile(path, 'original') + await createCachedSigner(f.options)({ ...f.request, path }) + assert.equal(await readFile(path, 'utf8'), 'signed:original') + assert.equal(f.state.hardwareCalls, 1) + assert.deepEqual(await readdir(directory), ['module.node']) +}) + +test('different content or signing policy misses the cache', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'changed') + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + await createCachedSigner({ ...f.options, identity: 'changed-policy' })(f.request) + assert.equal(f.state.hardwareCalls, 3) +}) + +for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + const entry = (await readdir(f.cache)).find(name => !name.startsWith('.')) + assert(entry) + await writeFile(join(f.cache, entry, corrupt), 'corrupt') + await assert.rejects(createCachedSigner(f.options)(f.request)) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(f.path, 'utf8'), 'original') +}) + +for (const signature of [ + { status: 'NotTrusted', timestamped: true, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) }, +]) test(`cache refuses signature ${JSON.stringify(signature)}`, async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => signature })(f.request), /invalid cached signature/u) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(await readFile(f.path, 'utf8'), 'original') +}) + +test('hardware failure rejects queued work without publishing an entry or retrying', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner({ ...f.options, sign: async () => { f.state.hardwareCalls++; throw new Error('hardware failed') } }) + const results = await Promise.allSettled([signer(f.request), signer(f.request)]) + assert(results.every(result => result.status === 'rejected')) + assert.equal(f.state.hardwareCalls, 1) + assert.deepEqual(await readdir(f.cache), []) +}) + +test('a failed new signature verification never publishes a cache entry', async (t) => { + const f = await fixture(t) + await assert.rejects(createCachedSigner({ ...f.options, inspect: async () => ({ status: 'NotTrusted', timestamped: false, thumbprint: null }) })(f.request), /new signature verification failed/u) + assert.deepEqual(await readdir(f.cache), []) +}) + +test('an incomplete entry is rejected without hardware access', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + const entry = (await readdir(f.cache))[0] + assert(entry) + await rm(join(f.cache, entry, 'record.json')) + await writeFile(f.path, 'original') + await assert.rejects(createCachedSigner(f.options)(f.request)) + assert.equal(f.state.hardwareCalls, 1) +}) + +test('certificate and toolchain content invalidate policy identity independently of paths', async (t) => { + const f = await fixture(t) + const certificate = join(f.root, 'certificate.cer') + const command = join(f.root, 'sign.cmd') + await writeFile(certificate, 'certificate A') + await writeFile(command, 'signing policy A') + const first = await signatureCacheIdentity([certificate, command]) + const relocated = join(f.root, 'relocated.cer') + await writeFile(relocated, 'certificate A') + assert.equal(await signatureCacheIdentity([relocated, command]), first) + await writeFile(certificate, 'certificate B') + assert.notEqual(await signatureCacheIdentity([certificate, command]), first) + await writeFile(command, 'signing policy B') + assert.notEqual(await signatureCacheIdentity([relocated, command]), first) +}) + +test('two writers publish one complete entry without overwriting another writer', async (t) => { + const f = await fixture(t) + const second = join(f.root, 'second.node') + await writeFile(second, 'original') + const entered = Promise.withResolvers() + let arrivals = 0 + const options: import('../scripts/windows-signature-cache.mjs').WindowsSignatureCacheOptions = { ...f.options, sign: async (request) => { + if (++arrivals === 2) entered.resolve(undefined) + await entered.promise + await f.options.sign(request) + } } + const results = await Promise.allSettled([ + createCachedSigner(options)(f.request), + createCachedSigner(options)({ ...f.request, path: second }), + ]) + for (const result of results) if (result.status === 'rejected') throw result.reason + assert.equal((await readdir(f.cache)).length, 1) + await writeFile(f.path, 'original') + await createCachedSigner(f.options)(f.request) + assert.equal(f.state.hardwareCalls, 2) + assert.equal(await readFile(f.path, 'utf8'), 'signed:original') +}) + +test('linked cache directories cannot redirect cache restoration', async (t) => { + const f = await fixture(t) + const elsewhere = join(f.root, 'elsewhere') + await mkdir(elsewhere) + await symlink(elsewhere, f.cache, process.platform === 'win32' ? 'junction' : 'dir') + await assert.rejects(createCachedSigner(f.options)(f.request), /unlinked directory/u) + assert.equal(f.state.hardwareCalls, 0) +}) + +test('a target changed during verification is never replaced with stale cache bytes', async (t) => { + const f = await fixture(t) + await createCachedSigner(f.options)(f.request) + await writeFile(f.path, 'original') + const inspect = async (candidate: string) => { await writeFile(f.path, 'new input'); return f.options.inspect(candidate) } + await assert.rejects(createCachedSigner({ ...f.options, inspect })(f.request), /target changed/u) + assert.equal(await readFile(f.path, 'utf8'), 'new input') + assert.equal(f.state.hardwareCalls, 1) + assert(!(await readdir(f.root)).some(name => name.startsWith('.signature-restore-'))) +}) diff --git a/apps/desktop/tests/windows-timestamp.spec.ts b/apps/desktop/tests/windows-timestamp.spec.ts new file mode 100644 index 0000000000..71e1cbc911 --- /dev/null +++ b/apps/desktop/tests/windows-timestamp.spec.ts @@ -0,0 +1,139 @@ +/** Timestamp retries use fresh signed inputs and cannot publish partial or substituted files. */ +import { it, expect, type TestContext } from 'vitest' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { completeWindowsSignature, type WindowsTimestampOptions } from '../scripts/windows-timestamp.mjs' + +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'dsh-timestamp-')) + t.onTestFinished(() => rm(root, { recursive: true, force: true })) + const path = join(root, 'input.exe') + const evidenceDirectory = join(root, 'evidence') + await mkdir(evidenceDirectory) + await writeFile(path, 'input') + const state = { hardware: 0, requests: [] as string[], events: [] as object[] } + const options: WindowsTimestampOptions = { + evidenceDirectory, thumbprint: 'A'.repeat(40), + sign: async (file) => { state.hardware++; await writeFile(file, 'signed:input') }, + timestamp: async (file) => { state.requests.push(await readFile(file, 'utf8')); await writeFile(file, 'signed:input:timestamp') }, + normalize: async (file) => { await writeFile(file, (await readFile(file, 'utf8')).replace(':timestamp', '')) }, + inspect: async file => ({ status: 'Valid', timestamped: (await readFile(file, 'utf8')).endsWith(':timestamp'), thumbprint: 'A'.repeat(40) }), + record: (event) => { state.events.push(event) }, + wait: async () => {}, + } + return { root, path, options, state } +} + +it('a failed timestamp cannot contaminate the next input or repeat hardware signing', async (t) => { + const f = await fixture(t) + const timestamp = f.options.timestamp + let requests = 0 + f.options.timestamp = async (path) => { + if (++requests === 1) { + expect(await readFile(path, 'utf8')).toBe('signed:input') + await writeFile(path, 'damaged by failed timestamp') + throw Object.assign(new Error('timestamp failed'), { code: 1 }) + } + await timestamp(path) + } + await completeWindowsSignature(f.path, f.options) + expect(f.state.hardware).toBe(1) + expect(requests).toBe(2) + expect(f.state.requests).toEqual(['signed:input']) + expect(await readFile(f.path, 'utf8')).toBe('signed:input:timestamp') + expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([]) +}) + +it('exhausted timestamp attempts retain evidence and leave the original untouched', async (t) => { + const f = await fixture(t) + let requests = 0 + f.options.timestamp = async () => { requests++; throw Object.assign(new Error('offline'), { code: 1 }) } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('offline') + expect(requests).toBe(3) + expect(f.state.hardware).toBe(1) + expect(await readFile(f.path, 'utf8')).toBe('input') + expect(await readdir(f.options.evidenceDirectory)).toHaveLength(1) + expect((await readdir(f.root)).filter(name => name.startsWith('.complete-signature-'))).toEqual([]) +}) + +it('retains staging when failure evidence cannot be stored', async (t) => { + const f = await fixture(t) + f.options.evidenceDirectory = f.path + let staging = '' + f.options.sign = async (path) => { staging = dirname(path); throw new Error('hardware failed') } + t.onTestFinished(async () => { if (staging) await rm(staging, { recursive: true, force: true }) }) + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('evidence could not be retained') + expect(await readdir(staging)).toEqual(['raw.exe']) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('uses short tool paths for deeply nested targets and publishes the completed bytes', async (t) => { + const f = await fixture(t) + const directory = join(f.root, 'nested'.repeat(20), 'dependency'.repeat(12)) + await mkdir(directory, { recursive: true }) + const path = join(directory, 'native-module.node') + await writeFile(path, 'input') + function checked(operation: (file: string) => Promise) { + return async (file: string) => { + expect(file.length).toBeLessThan(260) + expect(file.endsWith('.node')).toBe(true) + expect(dirname(file)).not.toBe(directory) + return operation(file) + } + } + f.options.sign = checked(f.options.sign) + f.options.timestamp = checked(f.options.timestamp) + f.options.normalize = checked(f.options.normalize) + f.options.inspect = checked(f.options.inspect) + await completeWindowsSignature(path, f.options) + expect(await readFile(path, 'utf8')).toBe('signed:input:timestamp') + expect(await readdir(directory)).toEqual(['native-module.node']) +}) + +for (const failure of [{ code: 'ENOENT' }, { code: 1, killed: true }, { code: 1, signal: 'SIGTERM' }]) { + it(`does not retry uncertain timestamp process failure ${JSON.stringify(failure)}`, async (t) => { + const f = await fixture(t) + let requests = 0 + f.options.timestamp = async () => { requests++; throw Object.assign(new Error('process failure'), failure) } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('process failure') + expect(requests).toBe(1) + expect(await readFile(f.path, 'utf8')).toBe('input') + }) +} + +for (const signature of [ + { status: 'Valid', timestamped: false, thumbprint: 'A'.repeat(40) }, + { status: 'Valid', timestamped: true, thumbprint: 'B'.repeat(40) }, + { status: 'HashMismatch', timestamped: true, thumbprint: 'A'.repeat(40) }, +]) it(`rejects successful commands with an invalid final signature ${JSON.stringify(signature)}`, async (t) => { + const f = await fixture(t) + f.options.inspect = async () => signature + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('verification failed') + expect(f.state.requests).toHaveLength(1) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('rejects changed contents even when the substituted file has the expected valid certificate', async (t) => { + const f = await fixture(t) + f.options.timestamp = async (path) => { await writeFile(path, 'signed:different-input:timestamp') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('signing content changed') + expect(await readFile(f.path, 'utf8')).toBe('input') +}) + +it('does not overwrite an input changed while its signature is being completed', async (t) => { + const f = await fixture(t) + const timestamp = f.options.timestamp + f.options.timestamp = async (path) => { await timestamp(path); await writeFile(f.path, 'new input') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('input changed before publication') + expect(await readFile(f.path, 'utf8')).toBe('new input') +}) + +it('does not retry hardware failures or request timestamps after them', async (t) => { + const f = await fixture(t) + f.options.sign = async () => { f.state.hardware++; throw new Error('hardware failed') } + await expect(completeWindowsSignature(f.path, f.options)).rejects.toThrow('hardware failed') + expect(f.state.hardware).toBe(1) + expect(f.state.requests).toHaveLength(0) + expect(await readFile(f.path, 'utf8')).toBe('input') +}) diff --git a/apps/web/package.json b/apps/web/package.json index acdb74244a..07823f0a29 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 2b2ba0e135..b1c57c60e7 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const currentMenuBox = await currentMenu.boundingBox() expect(currentTriggerBox).not.toBeNull() expect(currentMenuBox).not.toBeNull() - expect(Math.abs(currentMenuBox!.width - 218)).toBeLessThan(1) + expect(Math.abs(currentMenuBox!.width - 144)).toBeLessThan(1) expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width) expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1) @@ -183,7 +183,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await menu.waitFor() await captureAutoReviewState(page, 'experimental-narrow-menu') const menuBox = (await menu.boundingBox())! - expect(menuBox.width).toBeGreaterThanOrEqual(218) + expect(menuBox.width).toBeGreaterThanOrEqual(144) expect(menuBox.width).toBeLessThanOrEqual(360) expect(menuBox.x).toBeGreaterThanOrEqual(0) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 17a741dac4..46115eff44 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -91,6 +91,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() within(waitingRow).getByText('Waiting for answer') + within(waitingRow).getByText('Answer') // Opening a session reaches chat content through the RemoteMock transport. fireEvent.click(waitingTitle) @@ -103,7 +104,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn for (let index = 0; index < 3; index += 1) { fireEvent.click(await screen.findByRole('button', { name: 'Skip' })) } - fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) + const allowOnce = await screen.findByRole('button', { name: 'Allow once' }) + await waitFor(() => { + within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Approval') + }) + fireEvent.click(allowOnce) // The fixture mirrors all three token-meter projections, so the assembled // ContextMeter reaches its composition panel instead of only the occupancy diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 5f210b0bab..21e4f78bae 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -436,8 +436,8 @@ describe('web e2e: clickable links gallery', () => { // globe in the same seat. const repoLink = markdown.locator(`a[href="${REPO_URL}"]`) expect(await repoLink.count()).toBe(1) - const repoMark = await repoLink.locator('svg path').getAttribute('d') - const globeMark = await guideLink.locator('svg path').getAttribute('d') + const repoMark = await repoLink.locator('svg path').first().getAttribute('d') + const globeMark = await guideLink.locator('svg path').first().getAttribute('d') expect(repoMark).not.toBe(globeMark) await guideLink.hover() expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline') diff --git a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md index 19b2bcb58b..9fcc346ad2 100644 --- a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md +++ b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md @@ -1,8 +1,6 @@ - dialog "确认启用 Auto review(实验)?": - heading "确认启用 Auto review(实验)?" [level=2] - - button "关闭": - - img - - img + - button "关闭" - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 - checkbox "我已了解这些风险,并愿意继续" - text: 我已了解这些风险,并愿意继续 diff --git a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md index 6553ea113a..9e942e44b5 100644 --- a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md @@ -1,8 +1,6 @@ - menu: - menuitem "仅可查看" - - menuitem "工作区内修改": - - text: 工作区内修改 - - img + - menuitem "工作区内修改" - menuitem "完全权限" - menuitem "Auto review EXP": - text: Auto review diff --git a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md index ffb1c8bc36..f1b12d28b0 100644 --- a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md @@ -2,9 +2,7 @@ - /placeholder: 搜索… - listbox "/permission 匹配项": - option "仅可查看" - - option "工作区内修改" [selected]: - - text: 工作区内修改 - - img + - option "工作区内修改" [selected] - option "完全权限" - option "Auto review EXP": - text: Auto review diff --git a/apps/web/tests/expected/access-confirmation/ui.expected.md b/apps/web/tests/expected/access-confirmation/ui.expected.md index d554a96a72..af85499602 100644 --- a/apps/web/tests/expected/access-confirmation/ui.expected.md +++ b/apps/web/tests/expected/access-confirmation/ui.expected.md @@ -1,8 +1,6 @@ - dialog "确认启用完全权限?": - heading "确认启用完全权限?" [level=2] - - button "关闭": - - img - - img + - button "关闭" - paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 - checkbox "我已了解风险,并愿意继续" - text: 我已了解风险,并愿意继续 diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md index dc4045c5cc..6e0cbf8c14 100644 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md @@ -1,7 +1,6 @@ - dialog "复制预设 · 复制自 极简模式": - heading "复制预设 · 复制自 极简模式" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 - text: 标识符 - textbox "标识符": diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index e443599032..033bf298ae 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,60 +19,36 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - list: - listitem: - 'button "设为默认: 我的模式"': - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 - code: my-agent - - 'button "查看路径: 我的模式"': - - img - - text: 查看路径 - - 'button "复制: 我的模式"': - - img - - text: 复制 - - 'button "删除: 我的模式"': - - img - - text: 删除 + - 'button "查看路径: 我的模式"': 查看路径 + - 'button "复制: 我的模式"': 复制 + - 'button "删除: 我的模式"': 删除 - paragraph: - text: 预设文件: - code: {{presetRoot}}/my-agent - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index f06fa3d070..c28c733b10 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,42 +19,26 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - list: - listitem: @@ -74,29 +46,15 @@ - text: broken-yaml 加载失败 自定义 暂无描述。 - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - code: broken-yaml - - 'button "查看路径: broken-yaml"': - - img - - text: 查看路径 - - 'button "复制: broken-yaml" [disabled]': - - img - - text: 预设加载失败,不能复制 - - 'button "删除: broken-yaml"': - - img - - text: 删除 + - 'button "查看路径: broken-yaml"': 查看路径 + - 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制 + - 'button "删除: broken-yaml"': 删除 - listitem: - 'button "加载失败: 幽灵预设" [disabled]': - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。 - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - code: ghost - - 'button "查看路径: 幽灵预设"': - - img - - text: 查看路径 - - 'button "复制: 幽灵预设" [disabled]': - - img - - text: 预设加载失败,不能复制 - - 'button "删除: 幽灵预设"': - - img - - text: 删除 - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - 'button "查看路径: 幽灵预设"': 查看路径 + - 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制 + - 'button "删除: 幽灵预设"': 删除 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 358f72e593..471f6bbada 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,43 +19,25 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index e81602eb6a..c6b9f141bf 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,12 +1,7 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - text: / - - button "1 subagent": - - text: 1 subagent - - img -- img + - button "1 subagent" - text: Minimal mode -- button "More actions": - - img -- button "Open right sidebar": - - img +- button "More actions" +- button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/hero.expected.md b/apps/web/tests/expected/agent-preset-selection/hero.expected.md index a320fa3e2f..2fe7ad2c8f 100644 --- a/apps/web/tests/expected/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/hero.expected.md @@ -1,8 +1,2 @@ -- button "Choose workspace": - - img - - text: workspace - - img -- button "Standard mode": - - img - - text: Standard mode - - img +- button "Choose workspace": workspace +- button "Standard mode" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 3d7c7bebb2..d6dfa09aad 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,7 +1,5 @@ - menu: - - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - - img + - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows." - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance." diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index f23d5c693e..1634cf19fe 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -1,68 +1,47 @@ - banner: - navigation "Session hierarchy": - button "Clickable links gallery" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" -- button "Copy": - - img -- button "16 tool calls" [expanded]: - - text: 16 tool calls - - img +- button "Copy" +- button "16 tool calls" [expanded] - button "Write site/report.html +1 -0": - - img - - img - text: Write - button "site/report.html" - text: +1 -0 - button "Write a/style.css +1 -0": - - img - - img - text: Write - button "a/style.css" - text: +1 -0 - button "Write b/style.css +1 -0": - - img - - img - text: Write - button "b/style.css" - text: +1 -0 - button "Write site/index.html +1 -0": - - img - - img - text: Write - button "site/index.html" - text: +1 -0 - button "Write site/app.js +1 -0": - - img - - img - text: Write - button "site/app.js" - text: +1 -0 - button "Edit src/tokens.css +1 -1" [expanded]: - - img - text: Edit - button "src/tokens.css" - text: +1 -1 - button "Copy" - text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file" - button "Inspect" -- button "Tool call str_replace_editor · create" [expanded]: - - img - - text: Tool call str_replace_editor · create +- button "Tool call str_replace_editor · create" [expanded] - text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md" - button "Inspect" - text: Failed -- 'button "Write permission denied: c/broken.css"': - - img - - text: "Write permission denied: c/broken.css" +- 'button "Write permission denied: c/broken.css"' - button "Read docs/guide.md" [expanded]: - - img - text: Read - button "docs/guide.md" - text: docs/guide.md @@ -71,9 +50,7 @@ - button "Expand 4 more lines": … 4 more lines - text: guide line 9 guide line 10 guide line 11 guide line 12 - button "Inspect" -- button "Grep linkColor" [expanded]: - - img - - text: Grep linkColor +- button "Grep linkColor" [expanded] - text: 9 matches · 3 files - button "Copy" - button "a/style.css 3" [expanded] @@ -82,24 +59,14 @@ - button "src/tokens.css 3" [expanded] - text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);" - button "Inspect" -- button "Glob **/*.css": - - img - - img - - text: Glob **/*.css -- button "Bash List the built site": - - img - - img - - text: Bash List the built site -- button "Failed Bash Run the lint gate" [expanded]: - - img - - text: Failed Bash Run the lint gate +- button "Glob **/*.css" +- button "Bash List the built site" +- button "Failed Bash Run the lint gate" [expanded] - text: Failed site pnpm run lint exit code 1 - button "Copy" - text: "style.css: unexpected hex literal" - button "Inspect" -- button "Search clickable link styles, produced files ui" [expanded]: - - img - - text: Search clickable link styles, produced files ui +- button "Search clickable link styles, produced files ui" [expanded] - paragraph: - text: Unify links per - link "the guide": @@ -112,16 +79,12 @@ - text: One cursor token, one focus ring. - listitem: Mirror spec (non-http) A non-http source renders inert. - button "Inspect" -- button "Fetch https://docs.example.test/tokens" [expanded]: - - img - - text: Fetch https://docs.example.test/tokens +- button "Fetch https://docs.example.test/tokens" [expanded] - link "https://docs.example.test/tokens": - /url: https://docs.example.test/tokens - text: HTTP 200 - button "Inspect" -- button "Tool call design_tokens_sync · design-platform.css" [expanded]: - - img - - text: Tool call design_tokens_sync · design-platform.css +- button "Tool call design_tokens_sync · design-platform.css" [expanded] - text: "IN { \"source\": \"design-platform.css\", \"dryRun\": false } OUT {\"synced\":true,\"tokens\":12}" - button "Inspect" - heading "Link gallery" [level=2] @@ -179,28 +142,16 @@ - list: - listitem: - paragraph: Footnote references stay inert superscripts. ↩ -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps": - - img - - text: 1 turns 2 steps +- button "1 turns 2 steps" diff --git a/apps/web/tests/expected/cold-blank-session/queue.expected.md b/apps/web/tests/expected/cold-blank-session/queue.expected.md index 9fcb11cde6..c8aded928a 100644 --- a/apps/web/tests/expected/cold-blank-session/queue.expected.md +++ b/apps/web/tests/expected/cold-blank-session/queue.expected.md @@ -1,9 +1,6 @@ - list: - listitem: - text: Accepted before the Host restarted - - button "Edit queued message": - - img - - button "Remove queued message": - - img - - button "Steer queued message" [disabled]: - - img + - button "Edit queued message" + - button "Remove queued message" + - button "Steer queued message" [disabled] diff --git a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md index 19a61c0c94..34a0eb6663 100644 --- a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md @@ -4,7 +4,7 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px @@ -16,7 +16,7 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px @@ -28,10 +28,10 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px -- input card left edge moves between tabs: 4px -- input card right edge moves between tabs: 4px +- input card left edge moves between tabs: 2.5px +- input card right edge moves between tabs: 2.5px - input card width changes between tabs: 0px diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 4ed654fa1a..6e35dfef96 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -1,10 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -12,35 +10,15 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt": - - img - - img - - text: System prompt +- 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": - - img -- button "3 tool calls" [expanded]: - - text: 3 tool calls - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package.": - - img - - img - - text: Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package. -- 'button "Tool call cordis_inspect_self · {}"': - - img - - img - - text: "Tool call cordis_inspect_self · {}" -- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package.": - - img - - img - - text: 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]: - - img - - text: Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready +- button "Copy" +- button "3 tool calls" [expanded] +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." +- 'button "Tool call cordis_inspect_self · {}"' +- 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" - tab "Host" [selected] @@ -50,83 +28,44 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think The Host returned snap-1/pkg-1, so I will request its first activation.": - - img - - img - - text: Think The Host returned snap-1/pkg-1, so I will request its first activation. -- img +- 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 The activation request has been submitted, so I will return the requested readiness marker.": - - img - - img - - text: Think The activation request has been submitted, so I will return the requested readiness marker. +- button "Think The activation request has been submitted, so I will return the requested readiness marker." - paragraph: CORDIS_UI_READY -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 9/1 {{clock}} -- button "Thought for a while": - - text: Thought for a while - - img +- button "Thought for a while" - paragraph: The Cordis Plugin is running. -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - 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": - - img -- button "1 tool call" [expanded]: - - text: 1 tool call - - img -- img +- button "Copy" +- 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. - paragraph: CORDIS_UI_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 9/1 {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "3 turns 7 steps": - - img - - text: 3 turns 7 steps -- button "66.8K tok · Cache hit 77%": - - img - - text: 66.8K tokCache hit 77% +- button "3 turns 7 steps" +- button "66.8K tok · Cache hit 77%": 66.8K tokCache hit 77% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/declared-reasoning/ui.expected.md b/apps/web/tests/expected/declared-reasoning/ui.expected.md index 810a6bf8b5..2b18a73716 100644 --- a/apps/web/tests/expected/declared-reasoning/ui.expected.md +++ b/apps/web/tests/expected/declared-reasoning/ui.expected.md @@ -1,7 +1,5 @@ - menu "模型与推理等级": - - menuitemradio "Default" [checked]: - - text: Default - - img + - menuitemradio "Default" [checked] - menuitemradio "Off" - menuitemradio "High" - menuitemradio "Max" diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 15732b9c03..2a879d6b31 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -43,28 +31,18 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V41-Flash - - button "模型选项 1": - - img - - button "删除模型 1": - - img + - button "模型选项 1" + - button "删除模型 1" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2": - - img - - button "删除模型 2": - - img - - button "添加模型": - - img - - text: 添加模型 + - button "模型选项 2" + - button "删除模型 2" + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index bee6603b9b..37066e17e2 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,7 +1,5 @@ - menu "模型与推理等级": - group "DeepSeek": - text: DeepSeek - - menuitemradio "Messages Flash" [checked]: - - text: Messages Flash - - img + - menuitemradio "Messages Flash" [checked] - menuitemradio "DeepSeek-V4-Pro" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index af3b3a5753..138f34efad 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -1,62 +1,34 @@ - tree "Sessions": - - treeitem "{{workspace}}" [expanded]: - - img - - text: {{workspace}} + - treeitem "{{workspace}}" [expanded] - treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]: - text: Review deepseek-ai/deepseek-harness#314 - - button "Session actions for Review deepseek-ai/deepseek-harness#314": - - img + - button "Session actions for Review deepseek-ai/deepseek-harness#314" --- - banner: - navigation "Session hierarchy": - button "Review deepseek-ai/deepseek-harness#314" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt -- button "Thought for a while" [expanded]: - - text: Thought for a while - - img -- button "Context injection webhook github webhook handled by review-pr-when-ready": - - img - - img - - text: Context injection webhook github webhook handled by review-pr-when-ready -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt +- 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": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only -- button "Select model, current github-webhook-review-test/reply": - - text: github-webhook-review-test/reply - - img +- button "Select model, current github-webhook-review-test/reply": github-webhook-review-test/reply - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 77b217510e..a5be99f8ee 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -1,54 +1,32 @@ - tree "Sessions": - - treeitem "{{workspace}}" [expanded]: - - img - - text: {{workspace}} + - treeitem "{{workspace}}" [expanded] - treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]: - text: Review deepseek-ai/deepseek-harness#314 - - button "Session actions for Review deepseek-ai/deepseek-harness#314": - - img + - button "Session actions for Review deepseek-ai/deepseek-harness#314" --- - banner: - navigation "Session hierarchy": - button "Review deepseek-ai/deepseek-harness#314" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt -- button "Thought for a while": - - text: Thought for a while - - img +- button "System prompt" +- button "Thought for a while" - paragraph: "Review complete: no actionable findings." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only -- button "Select model, current github-webhook-review-test/reply": - - text: github-webhook-review-test/reply - - img +- button "Select model, current github-webhook-review-test/reply": github-webhook-review-test/reply - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/goal-bar/active.expected.md b/apps/web/tests/expected/goal-bar/active.expected.md index e6146d8a54..cf3227ee51 100644 --- a/apps/web/tests/expected/goal-bar/active.expected.md +++ b/apps/web/tests/expected/goal-bar/active.expected.md @@ -1,8 +1,4 @@ -- img - text: Ongoing Goal guard rapid clear clicks -- button "Pause goal": - - img -- button "Edit goal": - - img -- button "Clear goal": - - img +- button "Pause goal" +- button "Edit goal" +- button "Clear goal" diff --git a/apps/web/tests/expected/goal-bar/inactive.expected.md b/apps/web/tests/expected/goal-bar/inactive.expected.md index 457765ef78..729dbd1dd9 100644 --- a/apps/web/tests/expected/goal-bar/inactive.expected.md +++ b/apps/web/tests/expected/goal-bar/inactive.expected.md @@ -1,8 +1,4 @@ -- img - text: Inactive Goal guard rapid clear clicks -- button "Resume goal": - - img -- button "Edit goal": - - img -- button "Clear goal": - - img +- button "Resume goal" +- button "Edit goal" +- button "Clear goal" diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index e7fbb563ba..dd44da4896 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -1,25 +1,16 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - group "Command input": /goal -- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"': - - img - - img - - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" +- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"' - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index c600eb94a8..bc95d13b0e 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "CJK strong emphasis" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Render adjacent CJK strong emphasis. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: @@ -37,26 +34,15 @@ - strong: Warning! - text: 继续 - paragraph: CJK_STRONG_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 7db76e3b44..3794be0c0c 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "Markdown image policy" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" @@ -23,26 +20,15 @@ - paragraph: Missing image - paragraph: {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 12ff65dd6a..1428ede808 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -1,14 +1,12 @@ - banner: - navigation "Session hierarchy": - button "Inline code links" [disabled] - - button "More actions": - - img + - button "More actions" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Show the local preview URL. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" @@ -26,26 +24,15 @@ - text: "Unsafe:" - code: javascript:alert(1) - paragraph: INLINE_CODE_LINK_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 8afbbbb723..df043015fa 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "Math rendering" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Render this mathematical proof. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar @@ -32,26 +29,15 @@ - cell: - math: 1 5 - paragraph: MATH_RENDERING_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 75f37005ed..782e523ddb 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -37,9 +25,5 @@ - button "编辑 acme-gateway": 编辑 - button "删除 acme-gateway": 删除 - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index db8230f9d7..aa803a766c 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -49,10 +37,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: GPT-6 Astra - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 256K @@ -67,18 +53,12 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - listitem: - text: Acme 网关 自定义 - button "编辑 Acme 网关 (acme-gateway)": 编辑 - button "删除 Acme 网关 (acme-gateway)": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 1166b2cd12..3758c2af62 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - status: 已保存 minimax-cn。 @@ -29,9 +17,5 @@ - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 18a38f9843..e8b2579203 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -58,10 +46,8 @@ - text: acme-large - textbox "显示名称 1": - /placeholder: 显示名称 - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 256K @@ -74,14 +60,8 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index 97173d9c20..4844a60cfa 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -32,9 +20,5 @@ - text: Acme Gateway 自定义 - button "编辑 Acme Gateway (acme-gateway)": 编辑 - button "删除 Acme Gateway (acme-gateway)": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/delete.expected.md b/apps/web/tests/expected/models-settings/delete.expected.md index 5757ca52ca..ae7d02879b 100644 --- a/apps/web/tests/expected/models-settings/delete.expected.md +++ b/apps/web/tests/expected/models-settings/delete.expected.md @@ -1,7 +1,6 @@ - dialog "删除 minimax-cn?": - heading "删除 minimax-cn?" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 删除 minimax-cn 会移除其配置和存储的 API 密钥。 - button "取消" - button "删除 minimax-cn" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index b0e01b614d..6a369b9267 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index 7f42a844d7..e02c9b2529 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,6 @@ - dialog "选择要添加的模型": - heading "选择要添加的模型" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 - searchbox "搜索模型" - button "取消全选" diff --git a/apps/web/tests/expected/models-settings/native-delete.expected.md b/apps/web/tests/expected/models-settings/native-delete.expected.md index 6ff480db12..6d4a90ab32 100644 --- a/apps/web/tests/expected/models-settings/native-delete.expected.md +++ b/apps/web/tests/expected/models-settings/native-delete.expected.md @@ -1,7 +1,6 @@ - dialog "删除 minimax-cn?": - heading "删除 minimax-cn?" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。 - button "取消" - button "删除 minimax-cn" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index db14de42a9..4920e310b8 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -43,10 +31,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V41-Flash - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M @@ -66,18 +52,10 @@ - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2": - - img - - button "删除模型 2": - - img - - button "添加模型": - - img - - text: 添加模型 + - button "模型选项 2" + - button "删除模型 2" + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 6ce0338ab8..8bccdd9d12 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -44,10 +32,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: Private Preview - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M @@ -62,14 +48,8 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index aa5bb4ac1f..76ba608af1 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: diff --git a/apps/web/tests/expected/plan-review/temporary.expected.md b/apps/web/tests/expected/plan-review/temporary.expected.md index 246480407c..f3c15ee8a3 100644 --- a/apps/web/tests/expected/plan-review/temporary.expected.md +++ b/apps/web/tests/expected/plan-review/temporary.expected.md @@ -1,6 +1,5 @@ - region "Temporary review": - - button "Copy": - - img + - button "Copy" - heading "Temporary review" [level=1] - paragraph: Review without a tool invocation. - heading "Implementation" [level=2] diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 7f69fe8231..bd0a2a97e9 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index a68510932f..85b1d52b9e 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -1,6 +1,4 @@ -- button "返回 live-client": - - img - - text: live-client +- button "返回 live-client": live-client - heading "fixture-live-client" [level=3] - paragraph: - code: "@fixture/live-client" diff --git a/apps/web/tests/expected/plugin-config/subagent.expected.md b/apps/web/tests/expected/plugin-config/subagent.expected.md index 0ed952f6f1..e1214b7fd7 100644 --- a/apps/web/tests/expected/plugin-config/subagent.expected.md +++ b/apps/web/tests/expected/plugin-config/subagent.expected.md @@ -1,21 +1,17 @@ -- button "返回插件列表": - - img - - text: 插件列表 +- button "返回插件列表": 插件列表 - heading "Subagent" [level=3] - paragraph: 设置 Subagent 的递归层级、数量和模型。 - region "运行限制": - heading "运行限制" [level=3] - text: 最大递归深度 - - button "最大递归深度说明": - - img + - button "最大递归深度说明" - text: 已覆盖 - button "恢复默认" - textbox "最大递归深度": - /placeholder: "" - text: "2" - text: Subagent 并行数量上限 - - button "Subagent 并行数量上限说明": - - img + - button "Subagent 并行数量上限说明" - text: 已覆盖 - button "恢复默认" - textbox "Subagent 并行数量上限": diff --git a/apps/web/tests/expected/plugin-install-approve/blocked.expected.md b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md index b166febaf1..5b753ab623 100644 --- a/apps/web/tests/expected/plugin-install-approve/blocked.expected.md +++ b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md @@ -1,9 +1,6 @@ - dialog "插件安装失败": - - button "返回编辑": - - img - - text: 编辑 - - button "关闭": - - img + - button "返回编辑": 编辑 + - button "关闭" - alert: 插件安装失败 - paragraph: 有依赖的安装脚本需要你允许后才能继续 - paragraph: native-package @@ -17,6 +14,4 @@ - paragraph: 允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。 - paragraph: 只在信任这些包时允许。 - button "允许这些脚本并重试" - - button "查看安装详情": - - text: 查看安装详情 - - img + - button "查看安装详情" diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index aae03c2610..20815bb8fc 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -1,13 +1,10 @@ - dialog "添加插件": - heading "添加插件" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - text: 包名或地址 - textbox "包名或地址": - /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review - text: slow-package - - button "不知道该填什么?": - - img - - text: 不知道该填什么? + - button "不知道该填什么?" - button "安装" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 7302e8cc31..16fe2ceeab 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 023bd3b8bd..5c7e0abece 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index ea65a91653..dca8f5c457 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -2,8 +2,7 @@ - text: Files & folders - option "folderx/ Browse folder" [selected]: - text: folderx/ - - button "Browse folder": - - img + - button "Browse folder" - option "reference.txt" - text: Sessions - option "reference-order-target-session {{cwd}} · {{age}}" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 2e0920a20d..ac92c645bf 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -1,24 +1,16 @@ - banner: - navigation "Session hierarchy": - button "Reference order target" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Research notes what changed? Referenced session · Research notes {{clock}} -- button "Copy": - - img -- button "Session recall Research notes": - - img - - text: Session recall Research notes +- button "Copy" +- button "Session recall Research notes" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index f481cb6ddf..11dcbaea1a 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -1,53 +1,25 @@ - dialog "Settings": - navigation: - text: Settings - - button "General": - - img - - text: General - - button "Models": - - img - - text: Models - - button "Built-in plugins": - - img - - text: Built-in plugins - - button "Agent presets": - - img - - text: Agent presets - - button "Archived sessions": - - img - - text: Archived sessions + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Archived sessions" - button "Open configuration file" - - button "Close": - - img - - text: Close + - button "Close" - text: Permission Choose the default permission mode for new sessions - - button "Workspace Write": - - text: Workspace Write - - img + - button "Workspace Write" - text: Language - - button "English": - - text: English - - img + - button "English" - text: Appearance - - button "Light": - - img - - text: Light - - button "Dark": - - img - - text: Dark - - button "System" [pressed]: - - img - - text: System + - button "Light" + - button "Dark" + - button "System" [pressed] - text: Font size Only affects conversation content 14 - - button "Increase font size": - - img - - button "Decrease font size": - - img + - button "Increase font size" + - button "Decrease font size" - text: px Conversation display Controls process content in completed turns - - button "Compact": - - text: Compact - - img + - button "Compact" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - - button "Queue": - - text: Queue - - img + - button "Queue" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 581273fa84..3d89fa478b 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -1,53 +1,25 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - text: 权限 选择新会话的默认权限模式 - - button "工作区内修改": - - text: 工作区内修改 - - img + - button "工作区内修改" - text: 语言 - - button "中文": - - text: 中文 - - img + - button "中文" - text: 外观 - - button "浅色": - - img - - text: 浅色 - - button "深色": - - img - - text: 深色 - - button "跟随系统" [pressed]: - - img - - text: 跟随系统 + - button "浅色" + - button "深色" + - button "跟随系统" [pressed] - text: 字号大小 仅影响会话内容的字号 14 - - button "增大字号": - - img - - button "减小字号": - - img + - button "增大字号" + - button "减小字号" - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑": - - text: 紧凑 - - img + - button "紧凑" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - - button "排队发送": - - text: 排队发送 - - img + - button "排队发送" diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index cbc7dfaa51..4c654a7971 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -3,35 +3,29 @@ - button "tool-subagent-control, tool-subagent-control, 已启用": - strong: tool-subagent-control - text: 已启用 - - img - code: tool-subagent-control - listitem: - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": - strong: tool-subagent-control/list-agents - text: 已启用 - - img - code: tool-subagent-list-agents - listitem: - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent - listitem: - button "tool-subagent, tool-subagent-fork, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent-fork - listitem: - button "tool-subagent, tool-subagent-codex, 已停用": - strong: tool-subagent - text: 已停用 - - img - code: tool-subagent-codex - listitem: - button "tool-subagent, tool-subagent-claude-code, 已停用": - strong: tool-subagent - text: 已停用 - - img - code: tool-subagent-claude-code diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 01108aa7bc..e8536c690a 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -2,5 +2,4 @@ - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent diff --git a/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md index 62671cbb8b..e17e9cdfa1 100644 --- a/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md +++ b/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md @@ -1,6 +1,4 @@ - tree "Sessions": - - treeitem "workspace" [expanded]: - - img - - text: workspace + - treeitem "workspace" [expanded] - treeitem "New Session" [selected] - treeitem "1 subagent running Delegate a background job. now" diff --git a/apps/web/tests/expected/sidebar-terminal/guide.expected.md b/apps/web/tests/expected/sidebar-terminal/guide.expected.md index 4c38f09585..525fedae3f 100644 --- a/apps/web/tests/expected/sidebar-terminal/guide.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/guide.expected.md @@ -1,3 +1,2 @@ - button "New terminal Run commands in the Session workspace" -- button "Choose shell" [expanded]: - - img +- button "Choose shell" [expanded] diff --git a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md index 8636cb1da0..9612af2741 100644 --- a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md @@ -1,5 +1,3 @@ - menu: - - menuitem "bash": - - text: bash - - img + - menuitem "bash" - menuitem "sh" diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index a2a169cbdb..6be4b09786 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -1,62 +1,32 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - button "/user-invoke-demo" -- button "meeting notes.md": - - img - - text: meeting notes.md +- button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} -- button "Copy": - - img -- button "Thought for a while" [expanded]: - - text: Thought for a while - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Context injection user-invoke-demo": - - img - - img - - text: Context injection user-invoke-demo +- button "Copy" +- 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": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps · {{throughput}} tok/s": - - img - - text: 1 turns 1 steps{{throughput}} tok/s -- button "272 tok · Cache hit 0%": - - img - - text: 272 tokCache hit 0% +- button "1 turns 1 steps · {{throughput}} tok/s": 1 turns 1 steps{{throughput}} tok/s +- button "272 tok · Cache hit 0%": 272 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index dd04059876..84934122b6 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -1,54 +1,30 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - button "/user-invoke-demo" -- button "meeting notes.md": - - img - - text: meeting notes.md +- button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} -- button "Copy": - - img -- button "Thought for a while": - - text: Thought for a while - - img +- button "Copy" +- button "Thought for a while" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps · {{throughput}} tok/s": - - img - - text: 1 turns 1 steps{{throughput}} tok/s -- button "272 tok · Cache hit 0%": - - img - - text: 272 tokCache hit 0% +- button "1 turns 1 steps · {{throughput}} tok/s": 1 turns 1 steps{{throughput}} tok/s +- button "272 tok · Cache hit 0%": 272 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 171ce5ee7e..706dd17b41 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -1,10 +1,8 @@ - banner: - navigation "Session hierarchy": - button "{{workspace}}" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -38,436 +36,234 @@ - button "Jump to turn 27" - button "Jump to turn 28" - text: m1 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r1 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m2 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r2 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m3 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r3 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m4 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r4 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m5 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r5 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m6 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r6 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m7 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r7 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m8 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r8 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m9 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r9 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m10 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r10 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m11 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r11 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m12 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r12 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m13 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r13 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m14 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r14 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m15 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r15 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m16 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r16 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m17 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r17 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m18 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r18 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m19 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r19 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m20 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r20 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m21 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r21 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m22 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r22 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m23 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r23 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m24 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r24 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m25 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r25 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m26 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r26 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m27 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r27 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m28 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r28 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "28 turns 28 steps": - - img - - text: 28 turns 28 steps +- button "28 turns 28 steps" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 78e31b7a34..53fd9f27dc 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -1,42 +1,24 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- 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": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "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... - text: "Interjection: include the word BANANA in your final reply." -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply." -- button "Copy": - - img +- button "Copy" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Stop generating" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index a551fb4660..02f1062df9 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -1,68 +1,35 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- 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": - - img -- button "1 tool call" [expanded]: - - text: 1 tool call - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. -- button "Ask question 1/1 answered": - - img - - img - - text: Ask question 1/1 answered +- button "Copy" +- button "1 tool call" [expanded] +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - paragraph: "Got it: BANANA and ORANGE." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps · {{throughput}} tok/s": - - img - - text: 1 turns 2 steps{{throughput}} tok/s -- button "40 tok · Cache hit 0%": - - img - - text: 40 tokCache hit 0% +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "40 tok · Cache hit 0%": 40 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 9b81819095..5efb1d79ff 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -1,56 +1,32 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- 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": - - img -- button "1 tool call": - - text: 1 tool call - - img +- button "Copy" +- button "1 tool call" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - paragraph: "Got it: BANANA and ORANGE." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps · {{throughput}} tok/s": - - img - - text: 1 turns 2 steps{{throughput}} tok/s -- button "40 tok · Cache hit 0%": - - img - - text: 40 tokCache hit 0% +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "40 tok · Cache hit 0%": 40 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/thinking-markdown/ui.expected.md b/apps/web/tests/expected/thinking-markdown/ui.expected.md index a94d4bbf3c..ba8e88404d 100644 --- a/apps/web/tests/expected/thinking-markdown/ui.expected.md +++ b/apps/web/tests/expected/thinking-markdown/ui.expected.md @@ -1,6 +1,4 @@ -- button "Think" [expanded]: - - img - - text: Think +- button "Think" [expanded] - heading "Compact reasoning with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary" [level=2] - paragraph: - text: A paragraph with diff --git a/apps/web/tests/expected/workspace-management/grouping-options.expected.md b/apps/web/tests/expected/workspace-management/grouping-options.expected.md index 062fe37aff..4652128ef7 100644 --- a/apps/web/tests/expected/workspace-management/grouping-options.expected.md +++ b/apps/web/tests/expected/workspace-management/grouping-options.expected.md @@ -1,13 +1,9 @@ - menu: - text: Group by - - menuitem "WorkSpace": - - text: WorkSpace - - img + - menuitem "WorkSpace" - menuitem "Workspace Tree" - menuitem "In one list" - separator - text: Order by - menuitem "Manual" - - menuitem "Last updated": - - text: Last updated - - img + - menuitem "Last updated" diff --git a/apps/web/tests/expected/workspace-management/parent-folders.expected.md b/apps/web/tests/expected/workspace-management/parent-folders.expected.md index d8a6385a8f..b6e7907f10 100644 --- a/apps/web/tests/expected/workspace-management/parent-folders.expected.md +++ b/apps/web/tests/expected/workspace-management/parent-folders.expected.md @@ -1,11 +1,5 @@ -- treeitem "folder-group" [expanded]: - - img - - text: folder-group +- treeitem "folder-group" [expanded] - group: - - treeitem "project-two" [expanded]: - - img - - text: project-two + - treeitem "project-two" [expanded] - treeitem "New Session" [selected] - - treeitem "project-one" [expanded]: - - img - - text: project-one + - treeitem "project-one" [expanded] diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 95f2becd57..8896ca5c5c 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -1,11 +1,8 @@ - tree "Sessions": - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: - - img - text: {{workspace}} - - button "Workspace actions for {{workspace}}": - - img - - button "New session in {{workspace}}": - - img + - button "Workspace actions for {{workspace}}" + - button "New session in {{workspace}}" - treeitem "New Session" [selected] - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 177a16d8a4..23bdf194cd 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -55,6 +55,25 @@ describe('web e2e: goal bar clear convergence', () => { await expect.poll(() => pause.count(), { timeout: 10_000, }).toBe(1) + await pause.hover() + const pauseTooltip = page.getByRole('tooltip', { name: 'Pause goal', exact: true }) + await pauseTooltip.waitFor() + const tooltipGeometry = await page.evaluate(() => { + const element = document.querySelector('[role="tooltip"]') + if (element === null) return null + const tooltip = element.getBoundingClientRect() + return { + declaredLeft: Number.parseFloat(element.style.left), + declaredTop: Number.parseFloat(element.style.top), + tooltipCenter: tooltip.left + tooltip.width / 2, + tooltipTop: tooltip.top, + } + }) + expect(tooltipGeometry).not.toBeNull() + expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2) + expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2) + await page.mouse.move(0, 0) + await pauseTooltip.waitFor({ state: 'hidden' }) const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 193174dd51..8147022b05 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -391,14 +391,14 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // the wrapper focused, and focus-visible also reveals the bar. await page.mouse.move(4, 4) await wide.evaluate((element) => { element.blur() }) - await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 5px') // Resting hidden overflow keeps the scroll position reachable and intact. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) await wide.hover() await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px') // Pointer leaves: the bar rests hidden again. await page.mouse.move(4, 4) - await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 5px') expect(tripwire.pageErrors).toEqual([]) }, 120_000) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index cdca7a376b..1e1a94c1f4 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -96,6 +96,8 @@ describe('web e2e: plan review takeover round trip', () => { const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Plan review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await selectedRow.getByText('now', { exact: true }).count()).toBe(0) if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index bd39a95bbd..260792f545 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -162,6 +162,8 @@ describe('web e2e: resident question composer round trip', () => { const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await selectedRow.getByText('now', { exact: true }).count()).toBe(0) if (MODE !== 'record') { // This golden owns the stable question surface; the answered-state diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 44199c5b40..0f8f0f89fc 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -188,11 +188,27 @@ describe('web e2e: queue row actions', () => { await editRow.getByRole('button', { name: 'Edit queued message' }).click() const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) - await page.getByRole('button', { name: 'Save queued message' }).hover() - await page.getByRole('tooltip', { name: 'Save queued message', exact: true }).waitFor() + const save = page.getByRole('button', { name: 'Save queued message' }) + await save.hover() + const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true }) + await saveTooltip.waitFor() + const tooltipGeometry = await page.evaluate(() => { + const element = document.querySelector('[role="tooltip"]') + if (element === null) return null + const tooltip = element.getBoundingClientRect() + return { + declaredLeft: Number.parseFloat(element.style.left), + declaredTop: Number.parseFloat(element.style.top), + tooltipCenter: tooltip.left + tooltip.width / 2, + tooltipTop: tooltip.top, + } + }) + expect(tooltipGeometry).not.toBeNull() + expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2) + expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2) const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) - await settleQueueAction(() => page.getByRole('button', { name: 'Save queued message' }).click(), EDITED) + await settleQueueAction(() => save.click(), EDITED) await page.getByText(EDITED, { exact: true }).waitFor() const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE }) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index b8d8d36ebe..a45d2350a5 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -376,9 +376,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // the hover token included. expect(light.standardWidth).toBe('auto') expect(light.standardColor).toBe('auto') - // The pseudo-element path is the one in force: the sheet's own 8px sizing + // The pseudo-element path is the one in force: the sheet's own 5px sizing // and transparent track reached a container it never names. - expect(light.width).toBe('8px') + expect(light.width).toBe('5px') expect(light.track).toBe('rgba(0, 0, 0, 0)') // The resting and the hover rule each read the rebindable indirection, and // the two resolve to DIFFERENT colours on this list: the l1 pair arrived diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index ce84e0b76e..7cdc11770c 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -77,6 +77,48 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) + it('keeps the collapsed Skill line on the global ToolCall font-size axis', async () => { + const call = page.locator('[data-tool="skill"]') + const toggle = call.getByRole('button', { name: 'Skill editing-cordis-compositions' }) + if (await toggle.getAttribute('aria-expanded') === 'true') await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false') + const row = call.locator(':scope > div').first() + const title = call.getByText('Skill', { exact: true }) + const summary = call.getByText('editing-cordis-compositions', { exact: true }) + + expect(await title.evaluate(element => getComputedStyle(element).fontSize)).toBe('13px') + + const previous = await page.evaluate(() => ({ + fontSize: { + value: document.body.style.getPropertyValue('--dsh-content-font-size-secondary'), + priority: document.body.style.getPropertyPriority('--dsh-content-font-size-secondary'), + }, + delta: { + value: document.body.style.getPropertyValue('--dsh-content-font-delta'), + priority: document.body.style.getPropertyPriority('--dsh-content-font-delta'), + }, + })) + await page.evaluate(() => { + document.body.style.setProperty('--dsh-content-font-size-secondary', '15px') + document.body.style.setProperty('--dsh-content-font-delta', '2px') + }) + try { + expect(await title.evaluate(element => getComputedStyle(element).fontSize)).toBe('15px') + expect(await summary.evaluate(element => getComputedStyle(element).fontSize)).toBe('15px') + expect(await row.evaluate(element => getComputedStyle(element).height)).toBe('26px') + expect(await row.locator('svg').first().evaluate(element => getComputedStyle(element).width)).toBe('16px') + } finally { + await page.evaluate((saved) => { + const restore = (name: string, entry: { value: string; priority: string }): void => { + if (entry.value === '') document.body.style.removeProperty(name) + else document.body.style.setProperty(name, entry.value, entry.priority) + } + restore('--dsh-content-font-size-secondary', saved.fontSize) + restore('--dsh-content-font-delta', saved.delta) + }, previous) + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) }) diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 7b7eecd5d0..691a99c8e4 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,18 +1,12 @@ -- button "Agent Team" [expanded]: - - img - - text: Agent Team +- button "Agent Team" [expanded] - dialog "Agent Team": - strong: Agent Team - - button "Refresh Team": - - img - - button "Close": - - img + - button "Refresh Team" + - button "Close" - heading "Members" [level=3] - 'button "lead Idle · Model: deepseek-v4-flash" [disabled]' - heading "Shared tasks" [level=3] - - button "New task": - - img - - text: New task + - button "New task" - article: - strong: Browser task - text: Pending @@ -21,9 +15,5 @@ - combobox "Owner": - option "Unowned" [selected] - option "lead" - - button "Edit": - - img - - text: Edit - - button "Delete": - - img - - text: Delete + - button "Edit" + - button "Delete" diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index d155ce5042..9564bc493e 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -1,37 +1,24 @@ - banner: - navigation "Session hierarchy": - button "Stream one TypeScript fence for" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} -- button "Copy": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Copy" +- 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": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current streaming-fence-highlight-test/streaming-fence": - - text: streaming-fence-highlight-test/streaming-fence - - img +- button "Select model, current streaming-fence-highlight-test/streaming-fence": streaming-fence-highlight-test/streaming-fence - button "Stop generating" --- diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index efd3677f8e..9e3e33699f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -539,8 +539,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - treeBox!.x), Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // Menu padding alone insets the rows now that the border is gone. - ]).toEqual([4, 4]) + // Compact menu padding alone insets the rows now that the border is gone. + ]).toEqual([3, 3]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index cc7799325f..874380a78c 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md -web-styling.md: 1be0b629d2ccf54c5a00cfc8091a01e3d4d09bfd -web-styling.zh.md: 106880e964a399cba825e889b5e93048589d00e4 +web-styling.md: 2185abef4e2c0bad6e3ce1de7feed0316b7bc821 +web-styling.zh.md: fb836b1e434ff4bca2cf2c38146b902ef752d376 diff --git a/docs/web-styling.md b/docs/web-styling.md index 1be0b629d2..2185abef4e 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -22,8 +22,9 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. +- An elevated surface using the translucent `--dsw-specific-menu` fill applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` in the same rule. A surface containing fixed-position overlays paints both values on an isolated background pseudo-element so the filter does not change those overlays' containing block. Shared dropdowns use the compact `Menu` geometry; feature-owned menu content may vary without recreating the material or scrollbar implementation ([decision](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md)). - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. -- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). +- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 106880e964..fb836b1e43 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -22,8 +22,9 @@ - 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 +- 使用半透明 `--dsw-specific-menu` 填充的高层级表面,必须在同一规则中应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`。包含 fixed 定位浮层的表面把两个属性绘制在隔离的背景伪元素上,避免滤镜改变这些浮层的包含块。共享下拉菜单采用紧凑 `Menu` 几何;功能包自有菜单内容可以不同,但不得重新实现材质或滚动条([决定](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md))。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 -- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 +- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 ## 变更系统 diff --git a/package.json b/package.json index e7310d9553..16e6180821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 228ed34b52..f703576cbe 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 2386131657..4680b06f39 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3aad6db77f..8a20b05e13 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 3c74c670b0..643c553e0a 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 2516624567..a10ed37838 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index e4f015d4dd..35e9e26ae2 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index c673c1b66a..48881e37ce 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index 1e94a4d7f1..a8b036b554 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index f36412f79d..4ed94f2ea2 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index a595126838..5ae32b4c25 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 2e0df2adf4..1b66d184d1 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index bd1ff6a01f..30f5e459ab 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index 5311a21b49..fabd3700f1 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index c02845a836..0c907d8853 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 1f8f8e05a2..d017536b4e 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index c8a58ff21c..3d73f1324c 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 5002f896e1..8ef9704211 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 28621ba30f..428929dea0 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 362c579846..bdc3bd2c84 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 9e6a4a0cda..626f45a58d 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index aa70ffb352..6a322911e9 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 583db7134b..ea8af1e498 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 368b7d085a..c49d120753 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 2a3996337f..bdb36e09e0 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 14cac7743e..b75b622d52 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index 12da219572..43d4a98471 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -6,7 +6,7 @@ */ import { useState } from 'react' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' @@ -58,7 +58,7 @@ export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProp onClick={() => { setOpen(v => !v) }} > {activeLabel} - + )} /> diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 31fa0be7d6..65df885767 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index 84f669f4f5..59720c9836 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 1f0f18c65a..b83c090001 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e5ea57fa6a..067879042b 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index b40453e94a..34b3879a60 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -61,7 +61,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 1d79ca5474..68d3cd4a44 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -16,7 +16,7 @@ import { useEffect, useRef, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconAgentPresetOutline16, IconChevronDownOutline14, IconWarningOutline16, Menu, Toast, + IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -207,9 +207,9 @@ export function AgentPresetSeat({ disabled={state.busy} onClick={() => { setOpen(value => !value) }} > - + {shownLabel} - + )} /> @@ -217,7 +217,7 @@ export function AgentPresetSeat({ } + icon={} holdMs={REFUSAL_HOLD_MS} // The composer card, which is the content column this chip sits // above rather than inside — hence a page query, not `closest`. diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d275913ff5..5738a1fa36 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -13,8 +13,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, - IconPlusOutline16, IconTrashOutline16, Modal, Switch, Tag, Tooltip, + Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular, + IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -226,7 +226,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { props.close() }} > - + {t('creatorDraft')} ) @@ -369,7 +369,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${t('view')}: ${text.name}`} onClick={() => { void props.view(row.id) }} > - + ) : null @@ -381,7 +381,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} onClick={() => { void props.openLocation(row.id) }} > - + )} {row.trust === 'user' ? ( @@ -405,7 +405,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${t('delete')}: ${text.name}`} onClick={() => { props.confirmDelete(row.id) }} > - + ) : null} diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 196f1adfb3..47652605ca 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index 048eb3d2f4..82b20b469e 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -28,14 +28,6 @@ line-height: 18px; } -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - corner-shape: round; - background: var(--dsw-alias-state-warn-primary); -} - .body { display: flex; flex-direction: column; diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index dca3692899..a7c0b33477 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,6 +1,6 @@ /** Composer takeover for one pending approval waterfall. */ import { useState, type ReactNode } from 'react' -import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -28,9 +28,9 @@ function ApprovalFlow({ pending, detail, t }: { void pending.answer(outcome).catch(() => { setAnswered(false) }) } return ( -
+
-
{t('waiting')}
+
{t('waiting')}
{ render() expect(screen.getByText('Tool bash asks')).toBeTruthy() + expect(document.querySelector('[data-approval-key] [data-state="warning"]')).not.toBeNull() expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() expect(props.renderSlot).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') await expect(pending.result).resolves.toBe('rejected') expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) @@ -353,6 +355,8 @@ describe('ApprovalPanel', () => { callId: 'call-1', }) fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + expect(document.querySelector('[data-approval-key] [data-state="ongoing"]')).not.toBeNull() + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') await expect(pending.result).resolves.toBe('allowed-once') }) @@ -367,6 +371,7 @@ describe('ApprovalPanel', () => { await waitFor(() => { expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(false) }) + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('false') pending.abort(new Error('test cleanup')) await pending.result.catch(() => {}) }) diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 00a21ce93f..b2d93fd8aa 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index e2e8f97321..a5a3724803 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronLeftOutline14, IconChevronRightOutline14, + IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './AttachmentRail.module.css' @@ -142,7 +142,7 @@ export function AttachmentRail({ items, labels, re aria-label={labels.scrollLeft} onClick={() => { page(-1) }} > - + )}
({ items, labels, re aria-label={labels.scrollRight} onClick={() => { page(1) }} > - + )}
diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index 3692302325..a8359cae61 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -66,7 +66,7 @@ export function FileCard({ aria-label={labels.remove} onClick={onRemove} > - + {state === 'uploading' && ( diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx index 1f1207e026..c2aff2eeb3 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import css from './ImageLightbox.module.css' /** Lightbox strings the owner resolves from its own locale namespace. */ @@ -56,7 +56,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: { , document.body, diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index be35aa650c..caf8fe08f8 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -135,7 +135,7 @@ export function MessageImage({ image, load, variant, labels }: { onClick={request} > {variant === 'thumbnail' - ? + ? : labels.loadFailed} ) @@ -154,7 +154,7 @@ export function MessageImage({ image, load, variant, labels }: { {src === null ? ( - {loadingThumbnail ? : labels.loading} + {loadingThumbnail ? : labels.loading} ) : {label}} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index ca2e1e1aa7..7c9054890d 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -87,7 +87,7 @@ export function ComposerAttachments({ aria-label={t('image.remove', { name: attachment.file.name })} onClick={() => { onRemoveAttachment(attachment.id) }} > - +
) diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 24c8702e21..3fd7446384 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1d6df37e4d..78519947ae 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0 -README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb +README.md: 92c71663e9193e24657a31e57204a9bf076e19c3 +README.zh.md: 702042384b81d028b7db40275e5ee705dc949fb2 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index e7d3efe37e..92c71663e9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -16,6 +16,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Reference previews](#reference-previews) - [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) - [Turn Process Folding](#turn-process-folding) @@ -38,6 +39,13 @@ HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordi 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. + +## Command and failure rows + +Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. A terminal Turn failure remains a separate red-dot notice; intermediate model retries do not create that notice, and an output-token limit uses the amber warning dot. + +----- + ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8b49332ad3..702042384b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -16,6 +16,7 @@ kind: "package-reference" - [引用预览](#reference-previews) - [系统提示词行](#system-prompt-row) +- [指令与失败行](#command-and-failure-rows) - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) @@ -38,6 +39,11 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 + +## 指令与失败行 + +通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要明确表达。终止轮次的错误仍是独立的红点提示;模型的中间重试不会创建该提示,达到输出 token 上限时使用琥珀色警告点。 + ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index d716f7b89b..46c3422a78 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 106ac889db..43af8243e1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -73,6 +73,6 @@ /* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */ .actions { margin-top: 16px; - /* Optical align with 28px icon hit targets that pad 6px past the glyph. */ + /* Optical align with the 28px action hit targets. */ margin-left: -6px; } diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 4c407c8e8a..12a6f42546 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,9 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { InboxState } from '@deepseek-ai/dsh-agent/types' -import { Button, IconChevronDownOutline14, MarkdownDelegateProvider, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' @@ -838,7 +840,7 @@ export function ChatView({ if (local !== null) toBottom(scrollerOf(local)) }} > - +
)} diff --git a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx index b6108daf1a..006b570557 100644 --- a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx @@ -3,9 +3,9 @@ import { memo, useMemo, useState } from 'react' import { - IconApiOutline14, - IconChevronDownOutline14, - IconChevronRightOutline14, + IconApiOutlineRegular, + IconChevronDownOutlineRegular, + IconChevronRightOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -56,13 +56,13 @@ export const CompactionItem = memo(function CompactionItem({ > - + - {open ? : } + {open ? : } {title ?? t('message.compaction')} diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index c905035119..49704cca03 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconContextInjectionOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' +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' @@ -38,8 +38,8 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont - : } + ? + : } chevronClassName={css.chevron} title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={producer.label === null ? undefined : ( diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index f12992a043..2864836301 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ -import { useState, type ReactNode } from 'react' +import { useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutline14, StateDot } 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' @@ -12,10 +12,6 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowSt return outcome.kind === 'error' ? 'error' : 'ok' } -function leadingFor(state: CommandRowState): ReactNode { - return state === 'error' ? : -} - /** Card props: the owner payload plus the render site's locale seat (plain prop). */ export interface GenericCommandCardProps extends CommandRowOwnerProps { t: ChatViewSlotProps['t'] @@ -44,7 +40,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={leadingFor(state)} + icon={} title={title} open={open} expandable={body !== null} diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index ad8f6edb6c..c5234236f6 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -9,9 +9,7 @@ height: calc(28px + var(--dsh-content-font-delta, 0px)); } -/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). - Both read the secondary tier so the user row's clock matches the assistant - tail's meta line. */ +/* The user clock keeps the secondary tier. */ .timeStart { padding-right: 12px; font-size: var(--dsh-content-font-size-secondary, 13px); @@ -20,16 +18,24 @@ white-space: nowrap; } -/* The assistant tail's clock sits at the row end after the stat pills; the - row's 8px flex gap spaces it, and it reads the secondary tier so it matches - the pill labels beside it. */ +/* Assistant-tail metadata keeps the smaller information size while sharing + the action icons' resting color. */ .timeEnd { - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); + color: inherit; white-space: nowrap; } +.endInfo { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-left: 8px; + color: var(--dsw-alias-label-tertiary); +} + /* Earlier user-authored flow rows and opted-in turn tails reveal the complete actions row on hover or focus-within. Opacity keeps layout stable, and devices without hover keep every row visible. */ @@ -68,13 +74,18 @@ cursor: pointer; } -/* The 15px action glyphs follow the same px delta as the text they serve; - the CSS edge overrides each svg's own width/height attributes. */ +/* Action glyphs follow the same px delta as the text they serve; the + assistant completion row gives them two extra pixels. */ .action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); } +.actions[data-clock='end'] .action svg { + width: calc(17px + var(--dsh-content-font-delta, 0px)); + height: calc(17px + var(--dsh-content-font-delta, 0px)); +} + .action:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 5476f1a5c0..fd2b674edd 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { - IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, + IconBranchOutlineRegular, IconCheckOutlineRegular, IconCopyOutlineRegular, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -80,11 +80,11 @@ export function MessageIconActions({ ) return ( -
+
{clock === 'start' ? clockEl : null} {extraActions} @@ -100,15 +100,16 @@ export function MessageIconActions({ data-unavailable={branchUnavailable || undefined} onClick={branchUnavailable ? undefined : onBranch} > - + )} {onBranch !== undefined && branchUnavailable && ( {t('message.branchUnavailable')} )} - {usageAction} - {clock === 'end' ? clockEl : null} + {clock === 'end' + ? {usageAction}{clockEl} + : usageAction}
) } diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 73289e8e3f..4d484af95b 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,6 +1,6 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ import { useMemo, useState } from 'react' -import { DisclosureRow, IconThinkOutline14, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' @@ -44,7 +44,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={} + icon={} title={t('message.think')} open={expanded} expandable diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.module.css b/packages/client/ui-chat/src/client/chat/StatsPills.module.css index a7e307ccfc..988d1aa6cb 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsPills.module.css @@ -1,4 +1,4 @@ -/* Two icon pills side by side under the composer, 13/20 tertiary text tier +/* Two icon pills side by side under the composer, 12/20 tertiary text tier with a hover-pill affordance; each pill's click-open panel wears the shared stat-dialog.module.css skin. */ @@ -9,7 +9,7 @@ min-width: 0; max-width: 100%; box-sizing: border-box; - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 0c4b7c1ad7..7141ab323e 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -7,7 +7,7 @@ import { memo, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: merges the sessionStats key into SessionProjectionMap for useProjection. @@ -164,7 +164,7 @@ function TimePill({ stats, t, dialog }: { return ( - + {label} @@ -180,7 +180,7 @@ function TimePill({ stats, t, dialog }: { aria-label={tps === null ? counts : `${counts} · ${tps}`} onClick={() => { setOpen(!open) }} > - + {label} {open && createPortal( @@ -193,7 +193,7 @@ function TimePill({ stats, t, dialog }: { >
- + {t('stats.dialog.title')}
@@ -254,7 +254,7 @@ function UsagePill({ usage, t, dialog }: { aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`} onClick={() => { setOpen(!open) }} > - + {totalText} {cacheHitText !== null && ( @@ -275,7 +275,7 @@ function UsagePill({ usage, t, dialog }: { >
- + {t('stats.dialog.usageTitle')} {exactCount(total, t)} diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx index 787c8fb7bf..0136acbfd9 100644 --- a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx +++ b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx @@ -1,6 +1,6 @@ import { memo, useState } from 'react' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { OpaqueBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -27,7 +27,7 @@ export function SystemPromptRow({ text, update = false, t }: SystemPromptRowProp return ( } + icon={} chevronClassName={css.chevron} title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')} open={open} diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index f9210e84db..4969b3f383 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' import css from './TurnProcessNodeView.module.css' @@ -54,7 +54,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ }} > {label} - + ) }) diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index 52058f8be7..58fe2fe556 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -13,9 +13,7 @@ margin-left: -6px; } -/* Same pill rules as the sibling `.action` icon buttons (28px hit height, - 15px glyph, tertiary → secondary on hover with the interactive hover - background), widened to carry the turn-total label. */ +/* The information pills use the action icons' resting color. */ .trigger { display: inline-flex; align-items: center; @@ -27,7 +25,7 @@ border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); font-variant-numeric: tabular-nums; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); white-space: nowrap; @@ -51,7 +49,7 @@ .trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } /* Narrow viewport: the pills collapse to bare icons with the sibling diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 0d64ff9e08..765650b5ca 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -4,7 +4,7 @@ // branch action in the tail's IconActions row, ahead of the plain clock text. import { createPortal } from 'react-dom' -import { IconClockOutline16, IconDatabaseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconClockOutlineRegular, IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts' @@ -61,7 +61,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { aria-expanded={open} onClick={() => { setOpen(!open) }} > - + {t('message.turnUsage.consumed', { total })} {open && createPortal( @@ -74,7 +74,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { >
- + {t('message.turnUsage.title')} {formatExactCount(usage.totalTokens, t)} @@ -140,7 +140,7 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan aria-expanded={open} onClick={() => { setOpen(!open) }} > - + {t('message.ranFor', { duration: formatRunDuration(runMs, t) })} {open && createPortal( @@ -153,7 +153,7 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan >
- + {t('message.turnTime.title')}
diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css index cc30711a68..dda23ed176 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -21,6 +21,7 @@ border: 0; border-radius: 12px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); font-size: 12px; diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index f2ab902852..b3b188c69c 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { TranscriptViewMode } from '../../chat-settings.ts' import type { ChatKey } from '../locale.ts' @@ -54,7 +54,7 @@ export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: T onClick={() => { setOpen(value => !value) }} > {t(selectedLabel)} - + ) diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index 00e87bdcb6..4edce88336 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -48,14 +48,13 @@ describe('chat flow font-size axis', () => { it('the message clock and action glyphs scale with the text they serve', () => { const actions = read('MessageIconActions.module.css') - // Both clocks read the secondary tier: the assistant tail's meta line - // (the whole-line usage trigger) and the user row's clock stay one step - // under the body size so the two rows match. + // The user clock reads the secondary tier. The assistant tail is one + // further pixel down, matching its usage trigger and supporting metadata. expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([ 'font-size: var(--dsh-content-font-size-secondary, 13px)', ])) expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px)', ])) expect(declarationsFrom(actions, '.action svg')).toEqual(expect.arrayContaining([ 'width: calc(15px + var(--dsh-content-font-delta, 0px))', @@ -92,7 +91,7 @@ describe('chat flow font-size axis', () => { it('the usage-details trigger reads the secondary tier like its clock label', () => { const css = read('TurnUsagePanel.module.css') expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f974b064c0..d62466703f 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2836,6 +2836,7 @@ describe('ChatView', () => { }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(fv.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(fv.getByText('指令失败')).toBeTruthy() expect(fv.getByText('失败')).toBeTruthy() diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index e1835e72ea..d2c9cfb939 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/src/client/PopupSelectView.module.css b/packages/client/ui-commands/src/client/PopupSelectView.module.css index f8ef1c15f0..85e8b6d30a 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.module.css +++ b/packages/client/ui-commands/src/client/PopupSelectView.module.css @@ -1,6 +1,4 @@ -/* Official popupSelect shell card: menu-surface tokens (same family as - * ui-primitives Menu.module.css — figma MenuDropdown r12 / hairline / - * elevation-prominent), anchored by the conversation.input.overlay slot. */ +/* popupSelect uses the same compact translucent card as the shared Menu. */ .card { /* The overlay anchor is a zero-height strip on the composer card's top @@ -9,7 +7,7 @@ bottom: calc(100% + 4px); left: 0; z-index: 100; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; min-width: min(220px, 100%); @@ -25,8 +23,9 @@ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); outline: none; @@ -42,11 +41,11 @@ .row { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: 8px; + gap: 6px; + padding: 5px 7px; + border-radius: 7px; cursor: pointer; - font-size: 13px; + font-size: 12px; color: var(--dsw-alias-label-primary); } @@ -83,7 +82,7 @@ .detail { flex: 1; min-width: 0; - font-size: 12px; + font-size: 11px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; @@ -97,19 +96,24 @@ color: var(--dsw-alias-label-primary); } +.check svg { + width: 14px; + height: 14px; +} + .status { - padding: 8px 10px; - font-size: 13px; + padding: 7px 8px; + font-size: 12px; color: var(--dsw-alias-label-tertiary); } .search { - margin: 2px 2px 4px; - padding: 6px 8px; + margin: 2px 2px 3px; + padding: 5px 7px; border: 0.5px solid var(--dsw-alias-border-inverted); border-radius: 8px; background: transparent; - font-size: 13px; + font-size: 12px; color: var(--dsw-alias-label-primary); outline: none; } @@ -117,9 +121,9 @@ .error { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; - font-size: 12px; + gap: 6px; + padding: 5px 7px; + font-size: 11px; color: var(--dsw-alias-state-error-primary); } @@ -130,11 +134,11 @@ } .retry { - padding: 2px 8px; + padding: 2px 7px; border: 0.5px solid var(--dsw-alias-border-inverted); border-radius: 6px; background: transparent; - font-size: 12px; + font-size: 11px; color: var(--dsw-alias-label-primary); cursor: pointer; } diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 8ce2cc64a3..f148ff9392 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -13,7 +13,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' @@ -168,7 +168,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { {option.badge !== undefined && {option.badge}} {option.detail !== undefined && {option.detail}} - {option.active === true && } + {option.active === true && }
))}
diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts index 0bcee9cfc9..32d03930b2 100644 --- a/packages/client/ui-commands/src/client/presentation.ts +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -2,8 +2,8 @@ import type { ComponentType } from 'react' import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { - IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPaperPlaneOutline14, IconPlanOutline14, - IconShieldOutline16, + IconCompactOutlineRegular, IconDownloadOutlineRegular, IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, + PermissionIconFullAccessRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' @@ -39,12 +39,12 @@ function hostFace(name: BuiltinCommandName, icon: ComponentType): rea /** Built-in Host commands whose client face this package owns. */ const HOST_FACES: ReadonlyMap = new Map([ - hostFace('goal', IconGoalOutline16), - hostFace('plan', IconPlanOutline14), - hostFace('feedback', IconPaperPlaneOutline14), - hostFace('compact', IconCompactOutline16), - hostFace('permission', IconShieldOutline16), - hostFace('export', IconDownloadOutline16), + hostFace('goal', IconGoalOutlineRegular), + hostFace('plan', IconPlanOutlineRegular), + hostFace('feedback', IconPaperPlaneOutlineRegular), + hostFace('compact', IconCompactOutlineRegular), + hostFace('permission', PermissionIconFullAccessRegular), + hostFace('export', IconDownloadOutlineRegular), ]) /** diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 9dab8a0307..10979ac26c 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -14,7 +14,7 @@ import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconGoalOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -360,7 +360,7 @@ describe('candidates', () => { name: 'goal', label: 'command:label.goal', description: 'command:description.goal', - icon: IconGoalOutline16, + icon: IconGoalOutlineRegular, hint: '', section: 'command:section.add', }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0bf28301d0..7d53cf613a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bc3b582f9d80e97c29e27dcaa680dc064ec30deb -README.zh.md: 21881e7933cdf46cf9e27a66f076fc40c48e4b1a +README.md: 3bd965a13402b37ef2f45380dc51facfef58e0f2 +README.zh.md: f348ed31200801e726ed5383a3e0c968f84ca2d6 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bc3b582f9d..3bd965a134 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. -The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 21881e7933..f348ed3120 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 -本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8a0c27daff..8a92b63714 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 79ffd97359..bb83fcdbef 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -99,7 +99,7 @@ interface FileCommandRegistry { register(contribution: { name: string label(): string - icon: typeof IconPaperclipOutline16 + icon: typeof IconPaperclipOutlineRegular available(session: { sessionId: SessionId }): boolean ui: { kind: 'action'; run(session: { sessionId: SessionId }): void } }): () => void @@ -209,7 +209,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { scope.effect(() => commands.register({ name: 'file', label: () => t('input.file'), - icon: IconPaperclipOutline16, + icon: IconPaperclipOutlineRegular, available: session => inputHub.canPickFiles(session.sessionId), ui: { kind: 'action', run: (session) => { inputHub.pickFiles(session.sessionId) } }, }), 'ui-conversation: File action') diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx index e517bfbf3f..a4dd75dbce 100644 --- a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx @@ -5,7 +5,7 @@ */ import clsx from 'clsx' import type { ReactNode } from 'react' -import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' +import { ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ReferenceIconKind } from '@deepseek-ai/dsh-client-ui-primitives' import css from './ReferenceChip.module.css' import referenceCss from './composer-editor.module.css' @@ -29,7 +29,7 @@ export function ReferenceChip({ label, appearance, invalid }: ReferenceChipProps {appearance === undefined ? @ - : } + : } {label} ) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index d61f52ef82..ceb15ad6f5 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -75,6 +75,9 @@ export const zh = { 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', 'todo.progress.pending': '{pending} 待处理', + 'todo.status.completed': '已完成', + 'todo.status.inProgress': '进行中', + 'todo.status.pending': '待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', @@ -237,6 +240,9 @@ export const en = { 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', 'todo.progress.pending': '{pending} pending', + 'todo.status.completed': 'Completed', + 'todo.status.inProgress': 'In progress', + 'todo.status.pending': 'Pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 04288d206f..516ba7e83a 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -27,15 +27,26 @@ .panel { position: relative; + isolation: isolate; overflow: hidden; width: 100%; padding: 2px 0; border-radius: 12px 12px 0 0; - background: var(--dsw-specific-tip); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.panel::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; +} + .panel::after { position: absolute; inset: 0; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index e9447afd69..22f0730795 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,9 +4,9 @@ import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-att import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, - IconTrashOutline16, projectUserText, Tooltip, + IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, + FileTypeIcon, fileSizeText, IconEditOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular, + IconTrashOutlineRegular, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' @@ -175,13 +175,13 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > - + {t('queue.count', { n: rowCount })} {!listVisible && pendingQueue.length > 0 && ( {t('queue.sending')} )} - {expanded ? : } + {expanded ? : } )} @@ -192,7 +192,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load return (
  • {/* Single-item strip has no count header, so the row itself carries the queue glyph. */} - {rowCount === 1 && } + {rowCount === 1 && } {editing?.id === row.id ? ( { void saveEdit() }} > - + @@ -261,7 +261,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load disabled={busy !== null} onClick={() => { setEditing(null) }} > - + @@ -281,7 +281,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load if (text !== null) setEditing({ id: row.id, text: text }) }} > - + @@ -298,7 +298,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ) }} > - + @@ -316,7 +316,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ) }} > - + @@ -328,7 +328,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load {listVisible && pendingQueue.map((submission) => { return (
  • - {rowCount === 1 && } + {rowCount === 1 && } {submission.attachments.length > 0 && ( {submission.attachments.map((attachment, index) => attachment.type === 'image' @@ -359,7 +359,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load title={t('queue.sending')} disabled > - +
  • } diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx index 07ebd615b3..cf8588067f 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { BusyEnterBehavior } from '../contract/composer-submission.ts' import type { ConversationKey } from '../locales.ts' import css from './EnterBehaviorRow.module.css' @@ -67,7 +67,7 @@ export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehavio onClick={() => { setOpen(value => !value) }} > {t(selectedLabel)} - + )} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index a6a3e4c189..989377497d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -52,6 +52,7 @@ border: 0; border-radius: 12px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 22be826845..40df87269b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import type { ReactNode, RefObject } from 'react' import { - FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, + FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ConversationContentProps } from '../contract/slots.ts' @@ -53,10 +53,10 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t } onClick={onClick} > {label === undefined - ? - : } + ? + : } {label ?? t('hero.chooseWorkspace')} - + ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8d321137c3..2ee0f6d16d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -76,12 +76,9 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* No-workspace trigger state: dashed l4 stroke marks the card as a pick-a- - workspace affordance rather than a live composer; hover answers in the - business blue to invite the click. Native `dashed` has a fixed browser - pattern, so the stroke is an ::after overlay: theme-token background masked - by an SVG dash ring (stroke-width 2 centered on the box edge = 1px visible - inside), which keeps the 22px radius and both themes. */ +/* No-workspace trigger state: an inset 1px dashed outline marks the card as a + workspace picker rather than a live composer. Keeping the complete stroke + inside the rounded box prevents clipped corner pixels from reading heavier. */ .cardWorkspaceTrigger { --dsw-elevation-stroke-color: transparent; cursor: pointer; @@ -90,12 +87,12 @@ .cardWorkspaceTrigger::after { content: ''; position: absolute; - inset: -1px; + inset: 0; + box-sizing: border-box; + border: 1px dashed currentColor; border-radius: 22px; - background: var(--dsw-alias-border-l4); - transition: background-color 100ms ease; - -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + color: var(--dsw-alias-border-l4); + transition: color 100ms ease; pointer-events: none; } @@ -107,7 +104,7 @@ } .cardWorkspaceTrigger:hover::after { - background: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-state-business-primary); } .accessory { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 319628d266..6a4ea12740 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -347,7 +347,7 @@ export const InputBar = memo(function InputBar({ } + icon={} anchor={cardRef.current} onDone={dismissToast} /> @@ -418,7 +418,7 @@ export const InputBar = memo(function InputBar({ onMouseDown={keepFocus} onClick={onToggleCommandMenu} > - + - - - - ) -} - -/** In-progress: business-blue ring fading out; CSS spins the svg. */ -function ProgressGlyph() { - const gradientId = useId() - return ( - - ) -} - -/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */ -function PendingGlyph() { - return ( - - ) -} - -function StatusGlyph({ status }: { status: TodoItem['status'] }) { +/** Map Todo lifecycle state onto the shared compact status language. */ +function statusDotState(status: TodoItem['status']): StateDotState { switch (status) { - case 'completed': return - case 'in_progress': return - case 'pending': return + case 'completed': return 'done' + case 'in_progress': return 'ongoing' + case 'pending': return 'idle' + /* v8 ignore next -- closed TodoItem status union */ + default: return assertNever(status) + } +} + +/** Return the localized status announced beside one decorative marker. */ +function statusLabel(status: TodoItem['status'], t: TodoPanelProps['t']): string { + switch (status) { + case 'completed': return t('todo.status.completed') + case 'in_progress': return t('todo.status.inProgress') + case 'pending': return t('todo.status.pending') /* v8 ignore next -- closed TodoItem status union */ default: return assertNever(status) } @@ -98,18 +75,20 @@ export function TodoPanel({ todos, t }: TodoPanelProps) { aria-expanded={!collapsed} onClick={() => { setCollapsed(v => !v) }} > - + {t('todo.title')} {progressLabel(todos, t)} - {collapsed ? : } + {collapsed ? : } {!collapsed && (
      {todos.map(item => (
    • - + + + {item.content}
    • ))} diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index e79081b003..248c3cd92f 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -64,8 +64,10 @@ describe('TodoPanel', () => { expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() - // Each status row carries an SVG glyph (not a text bullet). - expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) + expect(items.map(li => li.querySelector('[data-state]')?.getAttribute('data-state'))) + .toEqual(['done', 'ongoing', 'idle']) + expect(screen.getAllByRole('img').map(node => node.getAttribute('aria-label'))) + .toEqual(['已完成', '进行中', '待处理']) }) it('collapse hides an expanded list; expand restores; header keeps the count summary', () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 041ec3fa6a..4544ccd159 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 46e289ddb572928baaee7ac1a370087fd789aebe -README.zh.md: 804ee5c7613510df96ac48cfe08f9b0fd39f7bbc +README.md: 8a2da4e763d3682dcf45c4a6f9529804c5d1bcc8 +README.zh.md: 7af5571e0fa5aeda323424fd54df9fa7e3878365 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 46e289ddb5..8a2da4e763 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row retains the delivery-document glyph while showing running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 804ee5c761..7af5571e0f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行在正在交付、已交付、失败或中断状态下都保留交付文档图标;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 28ed49b7fe..707f7728ad 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index cb0d5c1a05..6fd4e72ace 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,7 +1,7 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' -import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ChangesSummary } from '../changes.ts' import { IconCodeBracketsOutline16 } from './icons.tsx' @@ -66,7 +66,7 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { aria-label={t(expanded ? 'changes.collapseAria' : 'changes.expandAria', { count: String(changes.files.length) })} onClick={() => { setExpanded(value => !value) }}> {t(expanded ? 'changes.collapse' : 'changes.all', { count: String(changes.files.length) })} - {expanded ? : } + {expanded ? : } }
    } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 112bc68070..e463734319 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,7 +1,7 @@ /** The changed-files card, shown only while the Host serves the turn's summary, and explicitly declared files for a closing turn. */ import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' @@ -110,7 +110,7 @@ export function Deliverables({ aria-label={t(expanded ? 'presented.collapseAria' : 'presented.expandAria', { count: matched.presented.length })} onClick={() => { setExpanded(value => !value) }}> {t(expanded ? 'presented.collapse' : 'presented.all', { count: matched.presented.length })} - {expanded ? : } + {expanded ? : } }
    } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx index 845d2b4dcc..04c37677fd 100644 --- a/packages/client/ui-deliverables/src/client/PresentRow.tsx +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -1,6 +1,6 @@ /** Present call status and expandable durable result text. */ import { useState } from 'react' -import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconDeliverDocRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { NS } from './locales.ts' @@ -34,7 +34,7 @@ export function PresentRow({ block, inspect, t }: PresentRowProps) { const [expanded, setExpanded] = useState(false) return
    } + icon={} open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen onToggle={() => { setExpanded(value => !value) }} collapsedContent={{t(`row.${state}`)}{fileNames(args)}}> diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 5466473999..97fd431e32 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -2,8 +2,8 @@ import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, - IconChevronDownOutline14, IconFolderOpenOutline16, + Menu, FileTypeIcon, fileExtension, IconRightUpOutlineRegular, + IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedAction, PresentedHost } from '../presented.ts' @@ -70,12 +70,12 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, aria-haspopup="menu" aria-expanded={menuOpen && !menuDisabled} aria-label={t('presented.more', { name: file.path })} onClick={() => { setMenuOpen(value => !value) }}> - + } items={[ - { id: 'open', icon: , + { id: 'open', icon: , label: t('presented.defaultApp') }, - { id: 'reveal', icon: , + { id: 'reveal', icon: , label: t(`presented.${reveal}`) }, ]} onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 35a4350e6c..7ea29b8e45 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -6,7 +6,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { - Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip, + Button, IconChevronDownOutlineRegular, IconCodeOutlineRegular, IconPanelLeftOutlineRegular, + IconRightUpOutlineRegular, IconWrapLinesOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -207,7 +208,7 @@ export function ReviewTab({ aria-label={t('review.selectFile')} title={file.display} data-review-file={file.path} onClick={() => { setMenuOpen(value => !value) }}> {file.display} - + } items={files.map((entry, at) => ({ id: String(at), label: {entry.display} @@ -219,20 +220,20 @@ export function ReviewTab({ + onClick={() => { actions.toggledSplit(tab.id) }}> + onClick={() => { actions.toggledWrap(tab.id) }}> {file !== undefined && + onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } {file !== undefined && native && + onClick={() => { void openChanged(sessionId, seq, index) }}> }
    diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index 05d6b99540..870d54df97 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -20,6 +20,8 @@ it('discloses the saved result and offers call inspection', () => { const view = render() expect(view.getByText('Delivered')).toBeTruthy() expect(view.getByText('report.txt')).toBeTruthy() + expect(view.container.querySelector('[data-tool="present"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="present"] [data-state]')).toBeNull() expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull() const row = view.getByRole('button') fireEvent.keyDown(row, { key: 'Enter' }) @@ -37,6 +39,8 @@ it.each([ ] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => { const view = render() expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state) + expect(view.container.querySelector('[data-tool="present"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="present"] [data-state]')).toBeNull() expect(view.getByText(label)).toBeTruthy() expect(view.queryByText('Delivered')).toBeNull() }) diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 401e53de9c..1b4f6cddf7 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index d96e513c34..56eb0bece3 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -37,8 +37,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, - IconPlusOutline16, Modal, + Button, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconEditOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, + IconPlusOutlineRegular, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' @@ -253,10 +253,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr onClick={() => { onPick(entry) }} > {selected - ? - : } + ? + : } {entry.name} - + ) @@ -824,7 +824,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {crumbs.map((crumb, index) => ( - {index > 0 && } + {index > 0 && } ) @@ -964,7 +964,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
    diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 3f9fd7a43e..cf07905a2c 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the local Desktop or Host OS chooser", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index f557139f4f..bf7e254f8b 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index d929339953..a1efe4ce40 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -16,7 +16,7 @@ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' @@ -135,7 +135,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={(event) => { event.stopPropagation() }} onClick={() => { intents.unfloatPane(paneId) }} > - + {(canCloseTab?.(tab.id) ?? true) && ( @@ -148,7 +148,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={(event) => { event.stopPropagation() }} onClick={() => { intents.closeTab(tab.id) }} > - + )} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 47f025dab5..ae7d2e6fa7 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -23,7 +23,7 @@ import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' -import { IconCloseFill14, IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular, IconPlusOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' @@ -38,11 +38,12 @@ import css from './dockkit.module.css' */ const PANEL_FRAME = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z' -/** The split control's glyph: the panel frame with its divider moved to the centre. */ +/** The split control's glyph from the shared product artwork. */ function SplitGlyph(): ReactNode { return ( ) } @@ -336,7 +337,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { callbacks.onCloseTab(tabId) }} > - + )} {menu?.tabId === tabId && ( @@ -366,7 +367,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { callbacks.onAddTab(pane.id) }} > - + )} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index d1a95a74c3..55279ecbe1 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -450,10 +450,11 @@ display: flex; flex-direction: column; min-width: 96px; - padding: 4px; - background: var(--dsw-alias-bg-layer-3); + padding: 3px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 6px; + border-radius: 8px; } .menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) { @@ -461,13 +462,13 @@ } .menuItem { - padding: 5px 8px; + padding: 4px 7px; color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); text-align: left; background: transparent; border: none; - border-radius: 4px; + border-radius: 5px; cursor: pointer; } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 62cc82fdc4..36f5e15909 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: 4a2c0c18f41cd4965ee8170ec7a93843d7427769 -README.zh.md: 1d3d6dd7aef4a2b37c273a7a43e09bed05d33f9d +README.md: 7e6e0502db9720f788711fa3e98cab1581cc8738 +README.zh.md: 7e3fb604469ec73238f0843fdc95e6ea175c1d03 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 4a2c0c18f4..7e6e0502db 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce ## Use this package -Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective; clear removes the goal and suppresses the strip until the projection catches up. +Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective; clear removes the goal and suppresses the strip until the projection catches up. ### The command-input bubble diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 1d3d6dd7ae..7e3fb60446 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 ## 使用本包 -与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 +与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 ### 指令输入气泡 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 58d80d7bf5..40807cb930 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 31e78aecd1..8a53e74aa4 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -17,6 +17,8 @@ } .bar { + position: relative; + isolation: isolate; box-sizing: border-box; display: flex; align-items: center; @@ -26,9 +28,21 @@ height: 36px; margin: 0 auto; padding: 4px 5px 4px 12px; - border: 0.5px solid var(--dsw-alias-border-l1); + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); border-radius: 12px; - background: var(--dsw-specific-tip); + box-shadow: var(--dsw-elevation-panel); +} + +.bar::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; } .goalGlyph { diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 905bdb7631..e0ee681fd9 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -11,8 +11,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { GoalActivation, GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconGoalOutline16, - IconPauseOutline16, IconPlayOutline16, IconTrashOutline16, Tooltip, + IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconGoalOutlineRegular, + IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { GoalActionResult, GoalBarActions, GoalBarInjected } from './slots.ts' @@ -111,7 +111,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, disabled={pending || draft.trim() === ''} aria-label={t('action.save')} > - + @@ -122,7 +122,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, disabled={pending} aria-label={t('action.cancel')} > - +
    @@ -138,7 +138,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, return (
    - + {label} {goal.objective} {actionError !== null && {actionError}} @@ -146,14 +146,14 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, {goal.phase === 'active' && activation === 'armed' && ( )} {showResume && ( )} @@ -165,12 +165,12 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, onClick={() => { setDraft(goal.objective); setEditing(true) }} aria-label={t('action.edit')} > - +
    diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 82b67bce9f..d2a3fac7ee 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index cb6ff3ec3a..7b0a2818e4 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -15,17 +15,18 @@ (see ui-theme styles/scrollbar.css for the rebinding contract). */ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - /* A 10px draggable rail carries a quiet 4px thumb, inset from the card's + /* A 6px draggable rail carries a quiet 2px thumb, inset from the card's rounded ends. ui-theme's global scrollbar sheet renders these values. */ - --dsh-scrollbar-width: 10px; - --dsh-scrollbar-thumb-border: 3px; + --dsh-scrollbar-width: 6px; + --dsh-scrollbar-thumb-border: 2px; --dsh-scrollbar-track-margin: 12px; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } @@ -53,16 +54,16 @@ .item { display: flex; align-items: center; - gap: 8px; + gap: 6px; width: 100%; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; border: none; - border-radius: 10px; + border-radius: 8px; background: transparent; cursor: pointer; - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; } @@ -75,28 +76,33 @@ .sectionTitle { flex: none; - min-height: 26px; - padding: 6px 10px 2px; + min-height: 23px; + padding: 5px 8px 2px; color: var(--dsw-alias-label-tertiary); - font-size: 12px; + font-size: 11px; font-weight: 500; - line-height: 18px; + line-height: 16px; } .sectionTitle:not(:first-child) { - margin-top: 4px; + margin-top: 3px; } .itemIcon { display: inline-flex; flex: none; - width: 16px; - height: 16px; + width: 14px; + height: 14px; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); } +.itemIcon svg { + width: 14px; + height: 14px; +} + .itemName { flex: none; max-width: 40%; @@ -114,6 +120,8 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; } /* Right-aligned so every description ends at the same edge and the titles @@ -126,6 +134,8 @@ white-space: nowrap; text-align: right; color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; } /* Trailing seat on drillable rows (directories): the Tab keycap hint plus the @@ -134,7 +144,7 @@ flex: none; display: inline-flex; align-items: center; - gap: 4px; + gap: 3px; margin-left: auto; } @@ -144,20 +154,20 @@ .drillHintText { display: none; color: var(--dsw-alias-label-caption); - font-size: 11px; - line-height: 18px; + font-size: 10px; + line-height: 16px; white-space: nowrap; } .drillHint { display: none; - padding: 0 5px; + padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-caption); font-family: inherit; - font-size: 11px; - line-height: 18px; + font-size: 10px; + line-height: 16px; } .item.active .drillHintText, @@ -171,8 +181,8 @@ flex: none; display: inline-grid; place-items: center; - width: 20px; - height: 20px; + width: 18px; + height: 18px; border-radius: 4px; /* label-caption, the quiet tint the composer's access-mode chevron uses. */ color: var(--dsw-alias-label-caption); @@ -186,9 +196,9 @@ /* Heading row above a source group: non-interactive small grey text, * padding aligned with items (mirrors ui-primitives Menu .label). */ .groupTitle { - padding: 8px 10px; - font-size: 12px; - line-height: 16px; + padding: 6px 8px; + font-size: 11px; + line-height: 15px; color: var(--dsw-alias-label-tertiary); } @@ -198,15 +208,14 @@ .skeletonRow { display: flex; align-items: center; - /* border-box so min-height matches the .item buttons, whose UA default - box-sizing already counts the same 8px paddings inside their 40px. */ + /* border-box keeps the skeleton row aligned with the compact item buttons. */ box-sizing: border-box; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; } .skeletonBar { - height: 20px; + height: 18px; border-radius: 4px; background: var(--dsw-alias-bg-skeleton); animation: dsh-menu-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; @@ -219,16 +228,15 @@ } /* Breadcrumb header of a drilled source: pinned outside .viewport so the - descent stays reversible while the candidate list scrolls under it. - 4px sides + the crumb's own 6px = the first crumb's text sits at 10px, - flush with .sectionTitle and the item icons. */ + descent stays reversible while the candidate list scrolls under it. The + compact shell and crumb padding align its text with item content. */ .crumbs { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; - padding: 4px 4px 6px; + padding: 3px 3px 5px; margin-bottom: 2px; /* l1, not border-inverted (the menu frame's dark-only stroke): the divider needs to survive light mode too; in dark both resolve to white/0.06. */ @@ -240,15 +248,15 @@ flex: 0 1 auto; max-width: 40%; overflow: hidden; - padding: 2px 6px; + padding: 2px 5px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: inherit; - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index fc2bdc7cd1..a6395aff8b 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -14,7 +14,7 @@ */ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutlineRegular, ReferenceIconRegular, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -99,7 +99,7 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
    )} diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 42d6ee0a02..bf8f6061f6 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -238,7 +238,7 @@ describe('OpenInAppAction launching', () => { if (img !== null) fireEvent.error(img) await waitFor(() => { expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('svg rect')).not.toBeNull() + expect(container.querySelector('svg path[fill-opacity="0.05"]')).not.toBeNull() }) }) }) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index b868fdb882..69879368f4 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx index ac0913f6d6..2136974522 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconChevronDownOutline14, Menu, RiskConfirmation, + IconChevronDownOutlineRegular, Menu, RiskConfirmation, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PermissionSettingsState } from './settings-store.ts' import type { PermissionSettingsKey } from './locales.ts' @@ -97,7 +97,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP onClick={() => { setOpen(value => !value) }} > {label} - + )} /> diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx index 1736fd86a4..f34e2b16e9 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronDownOutline14, Menu, RiskConfirmation, SHIELD_OUTLINE_PATH, SHIELD_OUTLINE_STROKE, + IconChevronDownOutlineRegular, Menu, PermissionIconFullAccessRegular, + PermissionIconReadOnlyRegular, PermissionIconWorkspaceWriteRegular, RiskConfirmation, } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { @@ -21,34 +22,13 @@ import { } from './presentation.ts' import css from './PermissionSelect.module.css' -/* Shield glyphs (design set 1556) over the ui-primitives shield contour: - check = read-only, pencil = workspace write, exclamation = full access. - currentColor so the trigger and menu rows tint them with their own text - color. */ +/* Permission glyphs follow currentColor so the trigger and menu rows tint the + shared product artwork with their own text color. */ const permissionGlyphs = new Map([ - ['read-only', ( - - - - - )], - ['workspace-write', ( - - - - - - - - )], - [FULL_ACCESS, ( - - - - - - )], + ['read-only', ], + ['workspace-write', ], + [FULL_ACCESS, ], ]) /** Glyph for a permission option value; host-configured names outside the design set get none. */ @@ -209,7 +189,7 @@ export function PermissionSelect({ {currentBadge} )} - + } diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 1a77508ca4..a9122c3f23 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 74e65cdefe..3b2e03951b 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,6 +1,6 @@ /** Persistent transcript cards and pending-review sidebar navigation. */ import { useEffect } from 'react' -import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { FileTypeIcon, IconChevronRightOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { shallowEqual } from '@deepseek-ai/dsh-client-store' import type {} from './plan-definition.ts' @@ -64,5 +64,5 @@ export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, ac actions.markOpened(identity) }, [identity, opened, openReview, review, requestKey, actions]) return + onClick={() => { openReview(review, requestKey) }}>{t('preview.full')} } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index f5494f106c..8cbd71d878 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -60,7 +60,7 @@ export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipPro > {t('chip.label')} - + {error !== null && {t('chip.exitFailed')}} diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index b417507c77..141dcf8e3c 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -1,6 +1,6 @@ /** Read-only Markdown viewer for logged plans and temporary review documents. */ import { useMemo } from 'react' -import { IconCopyOutline16, IconPlanOutline14, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCopyOutlineRegular, IconPlanOutlineRegular, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from './plan-resource.ts' @@ -35,7 +35,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { return (
    + onClick={() => { void writeClipboard(plan.markdown) }}>
    ) @@ -52,5 +52,5 @@ export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.rig const params = tab.tab.navigation.params const plan = isReviewPreviewAddress(tab.tab.navigation.address) ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value - return <>{plan?.title ?? tab.tab.title} + return <>{plan?.title ?? tab.tab.title} } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index e50cdb00de..dd15598bf9 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: e7128dd2146cc0553ab36fdef5c31470be7877f2 -README.zh.md: ff7706f1d9747f553bf91f45a76fa5f50472330d +README.md: 73209cb946567abdae88e41debdaab996269f749 +README.zh.md: 8dabeb8f12d463051189cd93a9e66a2bc4cfe70b diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index e7128dd214..73209cb946 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,7 +41,7 @@ A bundle's page shows its full package name under the title, the spec that insta ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows show their fiber phase as the Host runs them. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. ### Configuration pages @@ -73,7 +73,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th ### The store -`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. +`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. ### Configuration slots diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index ff7706f1d9..8dabeb8f12 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,7 +41,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 ### 配置页 @@ -73,7 +73,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### store -`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 +`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 ### 配置 slot diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 727cad9599..a999a0c537 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-manager", "description": "Plugin management for the dsh web client: the sidebar Plugins panel installs, enables, disables, retries, and composes installed plugin packages", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 2971a42af9..38875cb7c6 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -61,6 +61,12 @@ color: var(--dsw-alias-state-error-primary); } +.statusWithDot { + display: inline-flex; + align-items: center; + gap: 6px; +} + .banner { margin: 0; padding: 8px 12px; @@ -645,14 +651,6 @@ color: var(--dsw-alias-label-secondary); } -.wizardIcon[data-tone='done'] { - color: var(--dsw-alias-state-success-primary); -} - -.wizardIcon[data-tone='failed'] { - color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); -} - .wizardTitle { margin: 0; font-size: 18px; @@ -731,39 +729,7 @@ min-width: 0; } -.spinnerLarge { - width: 28px; - height: 28px; - border: 3px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); - border-top-color: var(--dsw-alias-label-primary); - border-radius: 50%; - corner-shape: round; - animation: spin 900ms linear infinite; -} - -.spinner { - width: 14px; - height: 14px; - flex: none; - border: 2px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); - border-top-color: var(--dsw-alias-label-primary); - border-radius: 50%; - corner-shape: round; - animation: spin 900ms linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - @media (prefers-reduced-motion: reduce) { - .spinner, - .spinnerLarge { - animation: none; - } - .detailsChevron { transition: none; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a7cec19607..65eece8d3f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -12,9 +12,10 @@ import { useEffect, useId, useState, type ReactNode } from 'react' import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client' import { - Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseOutline16, - IconCordisPluginOutline14, IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, - IconWarningOutline16, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, + Button, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, + IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCordisPluginOutlineRegular, + IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, + IconWarningOutlineRegular, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -74,13 +75,13 @@ const PHASE_KEYS = { unloading: 'rowPhaseUnloading', } satisfies Record -/** Status dot naming a live root-fiber phase: pending and unloading fibers do nothing; only loading is in progress. */ +/** Status dot naming a root-fiber phase; loading and unloading are live transitions. */ const PHASE_STATES = { pending: 'idle', loading: 'ongoing', active: 'done', failed: 'error', - unloading: 'idle', + unloading: 'ongoing', } satisfies Record /** The count line over a pack's components: the total, then only the states that occur. */ @@ -189,20 +190,20 @@ function RowsSection({ rows, t, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
    - +
    {configure?.has(row) === true ? ( ) : {row.rowId}} {row.moduleName}
    - + {rowStateText(row, t)} {toggle === undefined @@ -256,7 +257,7 @@ function CardHead({ title, t, onOpen, tags, description, end }: { }): ReactNode { return (
    - +
    @@ -280,11 +281,11 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { return ( <>
    - + {actions}
    @@ -381,7 +382,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
    - } /> + } />

    {row.rowId}

    @@ -438,7 +439,7 @@ function PackageDetail({ variant="outline" size="sm" className={css.danger} - icon={} + icon={} aria-label={t('uninstallLabel', { name: title })} disabled={busy || pkg.readOnlyReason !== undefined} onClick={onUninstall} @@ -628,7 +629,7 @@ function InstallDialog({ className={css.installDialog as string} footer={( )} @@ -657,7 +658,7 @@ function InstallDialog({ aria-controls={guideId} onClick={() => { setGuideOpen(open => !open) }} > -
    @@ -716,7 +717,7 @@ function InstallDialog({ ? : ( )} @@ -727,15 +728,13 @@ function InstallDialog({ disabled={pending && phase !== 'running'} onClick={phase === 'running' ? onCancelAndClose : onClose} > - +
    -
    ) @@ -80,8 +81,8 @@ export function ConnectionIndicator({ > {connecting diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f04ad8986a..f79b7e4720 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,6 +1,6 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutline14 } from './icons/index.tsx' +import { IconChevronDownOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' /** Shared 24px disclosure chrome for compact flow rows. */ @@ -61,12 +61,12 @@ export function DisclosureRow({ ? ( <> {icon} - + ) : icon const leading = open - ? + ? : collapsedLeading return ( diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index 0a88317401..9beeb360f9 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -53,8 +53,8 @@ const EXTENSION_TYPES: Readonly> = { astro: 'code', bat: 'code', cmd: 'code', - csv: 'code', - tsv: 'code', + csv: 'excel', + tsv: 'excel', html: 'html', htm: 'html', png: 'image', @@ -93,6 +93,13 @@ const EXTENSION_TYPES: Readonly> = { xls: 'excel', xlsx: 'excel', xlsm: 'excel', + xlsb: 'excel', + xlt: 'excel', + xltx: 'excel', + xltm: 'excel', + ods: 'excel', + ots: 'excel', + fods: 'excel', numbers: 'excel', } @@ -145,7 +152,7 @@ const LARGE_FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.22) translate(-14 -1 const FOLDER_MARK_TRANSFORM = 'translate(14 13.0693) scale(1.12) translate(-14 -13.0693)' function FileGlyph({ - size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false, + size, className, children, markTransform, muted = false, }: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode { return ( @@ -174,25 +181,32 @@ function FolderGlyph({ size, className }: IconProps): ReactNode { ) } +function SpreadsheetGlyph({ size, className }: IconProps): ReactNode { + return ( + + + + ) +} + function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode { switch (type) { case 'code': return ( - + ) - case 'excel': - return ( - - - - ) + case 'excel': return case 'folder': return case 'html': return ( - + + @@ -230,7 +244,7 @@ function glyph(type: TraditionalFileType, size: number, className: string | unde ) case 'video': return ( - + ) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 7a3f2e8b7f..c97008c3e8 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode, } from 'react' -import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx' +import { IconCheckOutlineRegular, IconCopyOutlineRegular, IconWrapLinesOutlineRegular } from './icons/index.tsx' import { Menu } from './Menu.tsx' import type { MenuEntry } from './Menu.tsx' import css from './JsonTree.module.css' @@ -148,8 +148,8 @@ function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: }} > {state === 'copied' - ? - : } + ? + : } )} items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} @@ -436,7 +436,7 @@ function JsonString({ stringWrapping.setDefault(next) }} > - + )} {renderCopy?.(true)} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 22b41fdd4f..d8f8347282 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -1,42 +1,29 @@ /** - * Leading category glyph for clickable artifact links (anchors, file - * mentions, produced-file chips). One component keyed by link category; - * every glyph renders fill="currentColor" and so rides the link's own - * color. Glyphs stay private to this module: the public surface is the - * category vocabulary, not the individual glyph components, so consumers - * cannot compose a glyph outside a link. Design sources: - * ic_globe_language_outline_20, ic_code_outline_20, ic_folder_outline_20, - * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. - * - * A `url` glyph whose destination names a well-known site is that site's own - * mark instead of the globe (`SiteGlyph.tsx`); every mark keeps the same - * currentColor-only rule. + * Leading current-color category glyphs for clickable artifact links. The + * public component keeps individual artwork private to the link vocabulary. */ import type { ReactNode } from 'react' import { classifyFileType, fileExtension } from './FileTypeIcon.tsx' import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts' import { siteGlyph } from './SiteGlyph.tsx' +import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx' import type { IconProps } from './icons/props.ts' +import { CodeBracketsArtwork, FolderCloseArtwork, GlobeOutlineArtwork } from './icons/shared-artwork.tsx' -/** - * Link categories with distinct leading glyphs. `url` and `folder` are - * destination categories the consumer states directly; the rest are file - * categories `classifyLinkPath` derives from a path. Code, web, and data - * files share the `code` glyph by design. - */ +/** Link categories with distinct leading glyphs. */ export type LinkIconKind = 'url' | 'folder' | 'code' | 'image' | 'document' | 'other' -/** Props for {@link LinkIcon}: the category plus the shared icon sizing seat. */ +/** Props for link icons: the category plus the shared icon sizing seat. */ export interface LinkIconProps extends IconProps { kind: LinkIconKind - /** - * Destination of a `url` link. A well-known site draws its own mark, so - * callers that know the destination should pass it; anything else keeps the - * globe. Other kinds ignore it — their destination is a path, not a site. - */ + /** Destination of a URL link; known HTTP(S) hosts render their site mark. */ href?: string | undefined } +interface WeightedLinkIconProps extends LinkIconProps { + strokeWidth: number +} + /** * Derive a file path's link-icon category from its extension. Unknown and * missing extensions fall to `other` (the plain-paper glyph). @@ -64,75 +51,27 @@ export function classifyLinkPath(path: string): LinkIconKind { } } -const GlobeGlyph = ({ size, className }: IconProps) => ( - - +const PhotoGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const CodeGlyph = ({ size, className }: IconProps) => ( - - - - +const PaperDocGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const FolderGlyph = ({ size, className }: IconProps) => ( - - - - -) - -const PhotoGlyph = ({ size, className }: IconProps) => ( - - - - -) - -const PaperGlyph = ({ size, className }: IconProps) => ( - - - -) - -const PaperDocGlyph = ({ size, className }: IconProps) => ( - - - - +const PaperGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) @@ -143,21 +82,37 @@ function assertNever(value: never): never { } /** - * Render the leading glyph for one clickable artifact link. - * @param props - The link category, the optional destination that can select a - * site mark, optional size (default 14px — the inline link text size these - * glyphs sit beside), and optional CSS class. - * @returns The category's SVG glyph, riding currentColor. + * Render the leading glyph for one clickable artifact link at one stroke weight. + * @param props - The link category, optional size (default 14px), and optional CSS class. + * @returns The category's decorative current-color SVG glyph. */ -export function LinkIcon({ kind, href, size = 14, className }: LinkIconProps): ReactNode { +function LinkIconArtwork({ kind, href, size = 14, className, strokeWidth }: WeightedLinkIconProps): ReactNode { switch (kind) { - case 'url': return siteGlyph({ href, size, className }) ?? - case 'folder': return - case 'code': return - case 'image': return - case 'document': return - case 'other': return + case 'url': return siteGlyph({ href, size, className }) ?? + case 'folder': return + case 'code': return + case 'image': return + case 'document': return + case 'other': return /* v8 ignore next -- closed-union backstop; only reached if a kind is forged */ default: return assertNever(kind) } } + +/** + * Render a regular one-pixel link icon. + * @param props - Link category, size, and optional class. + * @returns The regular decorative link glyph. + */ +export function LinkIconRegular(props: LinkIconProps): ReactNode { + return +} + +/** + * Render a medium 1.3px link icon. + * @param props - Link category, size, and optional class. + * @returns The medium decorative link glyph. + */ +export function LinkIconMedium(props: LinkIconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 0525f2c6e4..eef5526a1b 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -3,19 +3,19 @@ display: inline-flex; } -/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface, - * r12, elevation-prominent (hairline stroke in shadow), 4px inset padding. */ +/* Compact translucent dropdown card shared by feature menus. */ .list, .submenu { /* min-widths below are the design's outer card widths — include the pad. */ box-sizing: border-box; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; gap: 0; border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The @@ -27,13 +27,13 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* Primary card is 218 wide in the design across both hosts. */ +/* Primary cards grow with their content between compact and readable bounds. */ .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; - min-width: 218px; + min-width: 144px; max-width: 360px; } @@ -84,8 +84,8 @@ flex: none; display: flex; flex-direction: column; - margin-top: 4px; - padding-top: 4px; + margin-top: 3px; + padding-top: 3px; border-top: 0.5px solid var(--dsw-alias-border-l2); } @@ -93,21 +93,20 @@ position: relative; } -/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary, - * gap 8 between leading icon / label / trailing check. */ +/* Menu cell: compact text, icon, and selection spacing. */ .item { display: flex; align-items: center; - gap: 8px; + gap: 6px; width: 100%; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; border: none; - border-radius: 10px; + border-radius: 8px; background: transparent; cursor: pointer; - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; } @@ -125,33 +124,33 @@ } .denseList .item { - min-height: 34px; - padding-block: 5px; + min-height: 30px; + padding-block: 4px; } .denseList .label { - padding-block: 4px; + padding-block: 3px; } .list.compactList, .submenu.compactList { - min-width: 164px; + min-width: 156px; padding: 2px; border-radius: 7px; } .compactList .item { - min-height: 26px; - gap: 6px; - padding: 3px 7px; + min-height: 24px; + gap: 5px; + padding: 2px 6px; border-radius: 5px; - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 17px; } .compactList .itemIcon { - width: 14px; - height: 14px; + width: 12px; + height: 12px; } .compactList .separator { @@ -159,9 +158,9 @@ } .compactList .label { - padding: 4px 7px; - font-size: 11px; - line-height: 16px; + padding: 3px 6px; + font-size: 10px; + line-height: 15px; } .item:disabled { @@ -172,13 +171,25 @@ .itemIcon { display: inline-flex; flex: none; - width: 16px; - height: 16px; + width: 14px; + height: 14px; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); } +.itemIcon svg, +.check { + width: 14px; + height: 14px; +} + +.compactList .itemIcon svg, +.compactList .check { + width: 12px; + height: 12px; +} + .itemLabel { flex: 1; min-width: 0; @@ -223,30 +234,29 @@ /* Heading row: non-interactive small grey text, padding aligned with items. */ .label { - padding: 8px 10px; - font-size: 12px; - line-height: 16px; + padding: 6px 8px; + font-size: 11px; + line-height: 15px; color: var(--dsw-alias-label-tertiary); } /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 0.5px; - margin: 4px 2px; + margin: 3px 2px; background: var(--dsw-alias-border-l1); } -/* Nested card to the right of the parent row (figma 419:16920). +/* Nested card to the right of the parent row. * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in - * .list's 4px pad, so bottom: -4px matches the list's outer bottom edge. - * Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain - * `100% + 6px` collapses to ~2px between outer card edges. + * .list's 3px pad, so bottom: -3px matches the list's outer bottom edge. + * Horizontal: list pad (3px) + 6px card gap = 9px past itemWrap. * ::before bridges the full gap so the pointer can cross without mouseLeave. */ .submenu { position: absolute; top: auto; - bottom: -4px; - left: calc(100% + 10px); + bottom: -3px; + left: calc(100% + 9px); z-index: 101; min-width: 163px; } @@ -256,6 +266,6 @@ position: absolute; top: 0; bottom: 0; - left: -10px; - width: 10px; + left: -9px; + width: 9px; } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index bb04912293..bd56ee1309 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCheckOutline16 } from './icons/index.tsx' +import { IconCheckOutlineRegular } from './icons/index.tsx' import { usePointerGrace } from './pointer-grace.ts' import css from './Menu.module.css' @@ -374,7 +374,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o {entry.icon !== undefined && {entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} - {selected && selection === 'check' && } + {selected && selection === 'check' && } {subOpen && entry.submenu !== undefined && (
    diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 574b1f0f35..58438873a8 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCloseOutline16 } from './icons/index.tsx' +import { IconCloseOutlineRegular } from './icons/index.tsx' import css from './Modal.module.css' interface ModalBaseProps { @@ -67,7 +67,7 @@ export function Modal({

    {title}

    {description !== undefined && description !== '' && ( diff --git a/packages/client/ui-primitives/src/OnboardingSurface.module.css b/packages/client/ui-primitives/src/OnboardingSurface.module.css index e019061c21..c492e538e1 100644 --- a/packages/client/ui-primitives/src/OnboardingSurface.module.css +++ b/packages/client/ui-primitives/src/OnboardingSurface.module.css @@ -14,8 +14,7 @@ top: 80px; bottom: 0px; background: rgba(0, 0, 0, 0.24); - /* Mask-blur */ - backdrop-filter: blur(2px); + backdrop-filter: var(--dsw-mask-blur); } .onboardingStage { diff --git a/packages/client/ui-primitives/src/PermissionIcon.tsx b/packages/client/ui-primitives/src/PermissionIcon.tsx new file mode 100644 index 0000000000..4b7c047933 --- /dev/null +++ b/packages/client/ui-primitives/src/PermissionIcon.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react' +import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx' +import type { IconProps } from './icons/props.ts' + +interface PermissionIconArtworkProps extends IconProps { + strokeWidth: number +} + +function ReadOnlyArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +function WorkspaceWriteArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +function FullAccessArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +/** + * Render the read-only permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconReadOnlyRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the read-only permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconReadOnlyMedium(props: IconProps): ReactNode { + return +} + +/** + * Render the workspace-write permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconWorkspaceWriteRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the workspace-write permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconWorkspaceWriteMedium(props: IconProps): ReactNode { + return +} + +/** + * Render the full-access permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconFullAccessRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the full-access permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconFullAccessMedium(props: IconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/ReferenceIcon.tsx b/packages/client/ui-primitives/src/ReferenceIcon.tsx index a225eba8fb..0f3baad8b3 100644 --- a/packages/client/ui-primitives/src/ReferenceIcon.tsx +++ b/packages/client/ui-primitives/src/ReferenceIcon.tsx @@ -1,35 +1,49 @@ import type { ReactNode } from 'react' +import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx' +import type { IconProps } from './icons/props.ts' import { - IconBrowseOutline16, IconFolderClose16, -} from './icons/index.tsx' + BrowseOutlineArtwork, FolderCloseArtwork, NewChatOutlineArtwork, +} from './icons/shared-artwork.tsx' /** Reference domains with distinct composer and transcript glyphs. */ export type ReferenceIconKind = 'session' | 'file' | 'folder' /** Props shared by inline reference glyphs. */ -export interface ReferenceIconProps { +export interface ReferenceIconProps extends IconProps { kind: ReferenceIconKind - size?: number - className?: string | undefined +} + +interface WeightedReferenceIconProps extends ReferenceIconProps { + strokeWidth: number } /** * Render the icon that identifies one inline reference domain. * @param props - Reference kind, optional size, and optional CSS class. - * @returns The corresponding current-color SVG glyph. + * @returns The corresponding decorative current-color SVG glyph. */ -export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode { +function ReferenceIconArtwork({ kind, size = 16, className, strokeWidth }: WeightedReferenceIconProps): ReactNode { switch (kind) { - case 'session': - return ( - - - - ) - case 'file': return - case 'folder': return + case 'session': return + case 'file': return + case 'folder': return } } + +/** + * Render a regular one-pixel reference icon. + * @param props - Reference kind, size, and optional class. + * @returns The regular decorative reference glyph. + */ +export function ReferenceIconRegular(props: ReferenceIconProps): ReactNode { + return +} + +/** + * Render a medium 1.3px reference icon. + * @param props - Reference kind, size, and optional class. + * @returns The medium decorative reference glyph. + */ +export function ReferenceIconMedium(props: ReferenceIconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index 8990cb8e5b..7f5c6f49c2 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -3,7 +3,7 @@ * must gate a sensitive action behind an explicit checkbox. */ import { Button } from './Button.tsx' -import { IconWarningOutline16 } from './icons/index.tsx' +import { IconWarningOutlineRegular } from './icons/index.tsx' import { Modal } from './Modal.tsx' import css from './RiskConfirmation.module.css' @@ -65,7 +65,7 @@ export function RiskConfirmation({ )} >
    - +

    {description}