From 2c9eda5b6f752a48396ce22387f569d0712e3183 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 12:05:06 +0800 Subject: [PATCH 01/41] feat(client): add plan, compact, and shield glyphs for the composer menu --- .../client/ui-primitives/src/icons/index.tsx | 38 +++++++++++++++++++ .../ui-primitives/tests/icons.client.spec.tsx | 4 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4afc31089f..47682cfab3 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -934,3 +934,41 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => ( ) + +/** plan_outline_14 (design doc for #3567: the composer menu's Plan row glyph; drawn on a 14 grid) */ +export const IconPlanOutline14 = ({ size = 14, className }: IconProps) => ( + + + + + + + + +) + +/** compact_outline_16 (the composer's context-usage ring as a static glyph: a quiet track with one filled arc) */ +export const IconCompactOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** shield_outline_16 (design set 1556: the permission shield contour without a mode mark) */ +export const IconShieldOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 1b9a9c7773..4cae7397ed 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 21 figma extracts + eight product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(75) + it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(78) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { From 5b1bb021cf9984dd75bfedb0ccb1ff9a913e3214 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 12:28:40 +0800 Subject: [PATCH 02/41] feat(web): group, localize, and re-present the composer command menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button and a typed / open one menu with an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order; rows carry a glyph, a localized title and description, and the command name as an alias where the localized title differs. Built-in Host rows get their face from the ui-commands dictionaries when the catalog description is the canonical English text. A menu pick fills the locale's claim token (/计划 ) while the submission executes the catalog name, and a typed localized token resolves on Space and Enter in every locale. The File row is a new action contribution kind that opens the composer's file picker through the scoped slash/input-pick-files event; the paperclip button is removed and the + button reads "Add files or run commands". --- ...menu-sections-and-localized-rows.i18n.yaml | 6 + ...mposer-menu-sections-and-localized-rows.md | 37 +++++ ...ser-menu-sections-and-localized-rows.zh.md | 37 +++++ .../clickable-links-gallery/ui.expected.md | 4 +- .../conversation-expanded.expected.md | 4 +- .../conversation.expected.md | 4 +- .../goal-command-presentation/ui.expected.md | 4 +- .../markdown-cjk-strong/ui.expected.md | 4 +- .../expected/markdown-images/ui.expected.md | 4 +- .../markdown-inline-code-links/ui.expected.md | 4 +- .../expected/math-rendering/ui.expected.md | 4 +- .../reference-composer/order.expected.md | 4 +- .../skill-user-invoke/ui-expanded.expected.md | 4 +- .../expected/skill-user-invoke/ui.expected.md | 4 +- .../stats-paged-history/ui.expected.md | 4 +- .../expected/steer-all/mid-steer.expected.md | 4 +- .../steer-all/settled-expanded.expected.md | 4 +- .../expected/steer-all/settled.expected.md | 4 +- .../tests/goal-command-presentation.e2e.ts | 2 +- apps/web/tests/lifecycle-chrome.e2e.ts | 9 +- apps/web/tests/shipped-composition.e2e.ts | 2 +- .../mid-stream.expected.md | 4 +- .../client/connection/src/client/fixture.ts | 2 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 10 +- packages/client/ui-commands/README.zh.md | 10 +- .../client/ui-commands/src/client/contract.ts | 37 ++++- .../client/ui-commands/src/client/index.ts | 32 +++- .../client/ui-commands/src/client/locales.ts | 55 ++++++- .../ui-commands/src/client/presentation.ts | 132 +++++++++++++++++ .../client/ui-commands/src/client/service.ts | 111 +++++++++----- .../tests/browser-plugin.client.spec.ts | 24 ++- .../ui-commands/tests/service.client.spec.ts | 139 +++++++++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/input.ts | 13 ++ .../src/client/input/facade.ts | 24 +++ .../ui-conversation/src/client/input/hub.ts | 1 + .../ui-conversation/src/client/locales.ts | 6 +- .../src/client/skeleton/InputBar.tsx | 27 ++-- .../tests/input-bar.client.spec.tsx | 28 ++-- .../tests/input-matrix.client.spec.tsx | 2 +- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 3 +- packages/client/ui-input-trigger/README.zh.md | 3 +- .../src/client/MenuView.module.css | 14 ++ .../ui-input-trigger/src/client/MenuView.tsx | 15 +- packages/client/ui-input-trigger/src/types.ts | 15 +- .../tests/menu-view.client.spec.tsx | 20 +++ .../ui-model-selection/src/client/index.ts | 9 +- .../ui-model-selection/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 21 ++- .../tests/browser-plugin.client.spec.ts | 26 ++-- .../client/ui-primitives/src/rank-by-name.ts | 30 +++- .../tests/rank-by-name.client.spec.ts | 14 ++ .../feedback/command-feedback/src/index.ts | 2 +- .../tests/command-feedback.spec.ts | 2 +- packages/goal/command-goal/src/index.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 2 +- snapshots/web/bash-abort-row/ui.expected.md | 4 +- .../web/cordis-tool-round/ui.expected.md | 4 +- .../feedback-command/ack-expanded.expected.md | 4 +- .../web/feedback-command/ack.expected.md | 4 +- .../feedback-release/ack-expanded.expected.md | 4 +- .../web/feedback-release/ack.expected.md | 4 +- .../web/file-upload-round/ui.expected.md | 4 +- .../fresh-round-trip/ui-expanded.expected.md | 4 +- snapshots/web/fresh-round-trip/ui.expected.md | 4 +- .../ui-expanded.expected.md | 4 +- .../goal-multi-turn-actions/ui.expected.md | 4 +- .../command-menu-fuzzy.expected.md | 2 +- .../lifecycle-chrome/command-menu.expected.md | 16 +- .../web/lifecycle-chrome/hero.expected.md | 4 +- .../lifecycle-chrome/plan-active.expected.md | 4 +- .../reloaded-expanded.expected.md | 4 +- .../web/lifecycle-chrome/reloaded.expected.md | 4 +- .../cancel-expanded.expected.md | 4 +- .../web/live-interactions/cancel.expected.md | 4 +- .../live-interactions/error-auth.expected.md | 4 +- .../web/live-interactions/loading.expected.md | 4 +- .../retry-exhausted.expected.md | 4 +- .../retry-expanded.expected.md | 4 +- .../web/live-interactions/retry.expected.md | 4 +- .../running-draft.expected.md | 4 +- snapshots/web/message-actions/ui.expected.md | 4 +- snapshots/web/minimal-preset/ui.expected.md | 4 +- .../plan-review/approved-expanded.expected.md | 4 +- .../web/plan-review/approved.expected.md | 4 +- snapshots/web/ptc-round/ui.expected.md | 4 +- .../answered-expanded.expected.md | 4 +- .../question-composer/answered.expected.md | 4 +- .../question-composer/cancelled.expected.md | 4 +- .../web/queue-actions/collapsed.expected.md | 4 +- .../web/queue-actions/editing.expected.md | 4 +- .../web/queue-actions/failed.expected.md | 4 +- .../web/queue-actions/layout.expected.md | 4 +- .../preserved-expanded.expected.md | 4 +- .../web/queue-actions/preserved.expected.md | 4 +- .../web/queue-actions/sending.expected.md | 4 +- snapshots/web/queue-actions/ui.expected.md | 4 +- .../web/queued-image/delivered.expected.md | 4 +- snapshots/web/queued-image/queued.expected.md | 4 +- .../seeded-history/command-row.expected.md | 4 +- .../seeded-history/feedback-row.expected.md | 4 +- .../seeded-history/ui-expanded.expected.md | 4 +- snapshots/web/seeded-history/ui.expected.md | 4 +- snapshots/web/skill-tool-row/ui.expected.md | 4 +- .../web/steering/settled-expanded.expected.md | 4 +- snapshots/web/steering/settled.expected.md | 4 +- .../ui-expanded.expected.md | 4 +- .../web/subagent-conversation/ui.expected.md | 4 +- .../offline-composer.expected.md | 4 +- .../turn-tail-actions/completed.expected.md | 4 +- .../web/turn-tail-actions/focused.expected.md | 4 +- .../web/turn-tail-actions/running.expected.md | 4 +- .../web/turn-tail-actions/settled.expected.md | 4 +- .../usage-expanded.expected.md | 4 +- snapshots/web/web-search-round/ui.expected.md | 4 +- 119 files changed, 872 insertions(+), 380 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md create mode 100644 packages/client/ui-commands/src/client/presentation.ts 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 new file mode 100644 index 0000000000..fd023c5ddc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.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-08-composer-menu-sections-and-localized-rows.md +2026-09-08-composer-menu-sections-and-localized-rows.md: c8fc2226a15828de14f623fc582473e72e3ec1f1 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 5905c872ab583294aabf15a1b69f2dfe1312e4ba 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 new file mode 100644 index 0000000000..c8fc2226a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -0,0 +1,37 @@ +# Agent Note: Composer menu sections, localized rows, and the File row + +Status: implemented + +English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md) + +## Problem + +The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu. + +## Decision + +`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first. + +The six built-in Host commands get a client face from the `command` dictionaries — title, description, glyph, and claim token — when the catalog description equals the canonical English text; a scoped override or third-party command of the same name keeps its own description and only its section position follows the name. The English dictionary is byte-equal to the Host descriptors, and the `feedback` and `goal` descriptors are capitalized to match the other four. Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration. + +A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. `resolveCommandName` maps every localized token of every dictionary back to its command name, so `/计划` typed under any locale reaches the `plan` descriptor on Space and Enter, and a bare `/压缩` executes as `/compact`. + +The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare invocation consumes the trigger token and runs a client callback without submitting anything; it therefore never refuses an attachment-carrying draft. The row raises the scoped `slash/input-pick-files` event, which `ui-conversation` routes to the composer's hidden file input through `ComposerKeyboard.bindFilePicker` under the same gate as drop intake; the paperclip button is removed. 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. + +## Alternatives considered + +**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim. + +**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete. + +**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were. + +**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place. + +**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another. + +## Consequences + +The menu, the Host catalog, and the localized face are three separate facts joined by name and by the canonical English description; adding a built-in Host command means adding its `label.*`, `description.*`, and `token.*` keys, its glyph in `presentation.ts`, and its section position. The web e2e goldens and every expected ARIA snapshot that showed the paperclip or the `Commands` button changed in the same PR. A third-party command named like a built-in one is positioned by name but keeps its own description and gets no glyph, which is the intended reading of "the same name, another command". 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 new file mode 100644 index 0000000000..5905c872ab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Composer 菜单分节、本地化行与「文件」行 + +Status: implemented + +[English](2026-09-08-composer-menu-sections-and-localized-rows.md) | 中文 + +## 问题 + +composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列成 `name description`,全部小写,没有图标也不分组,旁边还有一个单独的回形针按钮用于添加文件。中文界面下各行仍是英文,因为宿主描述符只携带英文文案;只知道命令中文名的用户既搜不到它,也看不出该输入什么。Issue #3567 及其设计稿要求:按使用频次分成两个小节,每行带图标、标题左对齐、说明右对齐,英文标题首字母大写,中文标题与说明在两种语言下都能搜索并同时显示英文命令名,计划与目标的声明用中文填入,以及把「文件」入口放进菜单。 + +## 决策 + +`ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。 + +六个内置宿主命令在目录说明与规范英文文案相同时,从 `command` 词典取得客户端展示面:标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字。英文词典与宿主描述符逐字节相同,`feedback` 与 `goal` 的描述符改为首字母大写,与另外四个一致。贡献项携带 `label()`、`description()` 与 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。 + +菜单选中填入当前语言的声明 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输的 token 保留手输的写法作为声明,因为 composer 从它持有的 token 之后读取参数。`resolveCommandName` 把每本词典里的每个本地化 token 映射回命令名,因此在任何语言下键入的 `/计划` 在空格与回车裁决中都到达 `plan` 描述符,裸 `/压缩` 执行为 `/compact`。 + +「文件」行是一个 `action` 贡献项,这是 `CommandUiSpec` 新增的种类:裸调用消费掉触发 token 后运行一个客户端回调,不提交任何内容,因此绝不会拒绝带附件的草稿。该行触发会话作用域的 `slash/input-pick-files` 事件,`ui-conversation` 经 `ComposerKeyboard.bindFilePicker` 把它路由到 composer 隐藏的文件输入框,门槛与拖放入口相同;回形针按钮移除。`+` 按钮的无障碍名称与提示改为「添加文件或调用指令」。 + +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 + +## 考虑过的替代方案 + +**在宿主侧本地化说明。** 宿主没有语言设置,目录又被所有客户端共享;客户端已经拥有其他所有界面的产品文案(客户端界面文案归语言所有的决策),相等性判断还能让作用域覆盖或第三方描述符原样保留。 + +**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。 + +**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。 + +**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。 + +**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。 + +## 后果 + +菜单、宿主目录与本地化展示面是三份独立事实,靠名字与规范英文说明连接;新增一个内置宿主命令意味着补上它的 `label.*`、`description.*`、`token.*` 键、`presentation.ts` 里的图标与小节位置。Web e2e 基准与所有曾显示回形针或 `Commands` 按钮的 ARIA 快照在同一 PR 内更新。与内置命令同名的第三方命令按名字定位,但保留自己的说明且没有图标,这正是「同名的另一个命令」应有的解读。 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 1bc1752380..b2ce4917b6 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -193,9 +193,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 92887b13f2..e831e97200 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 @@ -51,9 +51,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": 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 aa35d67e04..2f42c100cf 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -43,9 +43,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": 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 d5fddccca9..928d0baf06 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -17,9 +17,7 @@ - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 8cccd6e2a7..a6c5d051b3 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -51,9 +51,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3e00a67cb0..f8cdccfdc6 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -30,9 +30,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 e042455176..9c6287906f 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 @@ -42,9 +42,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 20e51e6c63..2bfd849bec 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -46,9 +46,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7ea2707435..f8b952b2c5 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -16,9 +16,7 @@ - img - text: Session recall Research notes - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 f14b04d9a9..1472e0b212 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 @@ -43,9 +43,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 e0901950d2..119d08381d 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 6a3f9ecbb6..a4d19d808d 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -462,9 +462,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 11e83471a3..32b464ba62 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -34,9 +34,7 @@ - button "Copy": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": 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 b1644d07d5..f15b14ab09 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -53,9 +53,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index acbaf1b148..4503a0585b 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -41,9 +41,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 70ad37a799..734aa20b03 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -51,7 +51,7 @@ describe('web e2e: /goal human transcript presentation', () => { const input = page.locator('[data-composer-input]').first() await input.fill('/go') const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' }) + await menu.getByRole('option', { name: 'Goal Set or view the goal for a long-running task' }) .waitFor({ timeout: 10_000 }) await input.press('Tab') await expect.poll(() => input.textContent()).toBe('/goal ') diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 282aa0b1a9..86ba7af21b 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -71,12 +71,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher')) - const launcher = page.getByRole('button', { name: 'Commands' }) + const launcher = page.getByRole('button', { name: 'Add files or run commands' }) await launcher.click() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await menu.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('text: Add') expect(snapshot).toContain('text: Commands') expect(snapshot).not.toContain('text: Skills') expect(snapshot).not.toContain('text: Subagents') @@ -95,7 +96,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () )).toBeLessThan(1) await writeComposerDraft(page, input, '/cpt') await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ - 'compactCompact older conversation history', + 'CompactCompact older conversation history', ]) const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) @@ -112,10 +113,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd) const input = activePage.locator('[data-composer-input]').first() - await activePage.getByRole('button', { name: 'Commands' }).click() + await activePage.getByRole('button', { name: 'Add files or run commands' }).click() const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) await menu.waitFor({ timeout: 10_000 }) - await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() + await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click() await expect.poll(() => input.textContent()).toBe('/plan ') await input.press('Enter') const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 61acc027fd..da22e6c2ca 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -183,7 +183,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async try { expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({ name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, }) } finally { 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 1ca602fbd7..4e8286c760 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 @@ -27,9 +27,7 @@ - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current streaming-fence-highlight-test/streaming-fence": diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e9440fee7f..17f0b95e05 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2220,7 +2220,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, + { name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '', attachments: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index dc82321424..c5f908d731 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/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-commands/README.md -README.md: e1264d10bd8ff807d8f348339e0c055607579407 -README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea +README.md: 31fd9925c08393735c56de891bafe985cc00b683 +README.zh.md: 76e4955014365f1c9890c98600c7cf1c347afbb0 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index e1264d10bd..31fd9925c0 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. +Typing a `/` command in the composer opens the matching surface — a registered popup, a client action, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or an action, or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect` or `action`, and everything else is `execute`. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. ## Table of Contents @@ -29,7 +29,11 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so ### Kinds and decorations -A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. +A contribution is a client-owned command — a host-name collision fails loud. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, which submits nothing and therefore never refuses an attachment-carrying draft. The menu's File row is this package's own action; it opens the composer's file picker through the scoped `slash/input-pick-files` event wherever the composer accepts files, never on a subagent. A decoration adds a bare-invocation popup or action to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles; prefixes rank first, and a typed query lists the matches flat without the section headings. + +### Built-in row faces + +Host descriptors carry English text only, so `src/client/presentation.ts` owns the client face of the six built-in Host commands: a catalog row whose description equals the canonical English text gets its localized title, description, glyph, and claim token from the `command` dictionaries, while a scoped override or third-party command of the same name keeps its own description and only its section position follows the name; rows outside both section lists close the Commands section in catalog order. Under Chinese, picking Plan fills the composer with `/计划 ` and the submission still executes `/plan `; a typed `/计划` or `/目标` resolves the same way on Space and Enter in every locale, so a draft written under one locale submits under another. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass, so a locale change reaches the next menu open without re-registration. ### Attachment-carrying submissions @@ -43,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
Implementation internals — click to expand -`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. +`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. `presentation.ts` holds the section lists, the built-in row faces, and the localized-token resolution; `candidates` sections an empty query and ranks a typed one through the shared `rankByName`.
diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index c5323149ae..76e4955014 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 +在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、客户端动作、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`)或动作,或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect` 或 `action`,其余全部是 `execute`。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 ## 目录 @@ -29,7 +29,11 @@ kind: "package-reference" ### 种类与装饰 -贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 +贡献项是客户端自有命令——与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用在消费掉触发 token 之后运行的一个回调,它不提交任何内容,因此绝不会拒绝带附件的草稿。菜单里的「文件」行就是本包自己的动作;只要 composer 接受文件,它就经会话作用域的 `slash/input-pick-files` 事件打开 composer 的文件选择器,子智能体上不提供。装饰为**已存在的**宿主命令添加裸调用弹窗或动作:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名与标题的子序列;前缀排名最高,输入查询后匹配项平铺列出,不再带小节标题。 + +### 内置行的展示面 + +宿主描述符只携带英文文案,因此 `src/client/presentation.ts` 拥有六个内置宿主命令在客户端的展示面:目录行的说明与规范英文文案相同时,它从 `command` 词典取得本地化的标题、说明、图标与声明 token;同名的作用域覆盖或第三方命令保留自己的说明,只有小节位置跟随名字;不在两个小节清单里的行按目录顺序排在「指令」小节末尾。中文下选中「计划」会在 composer 填入 `/计划 `,提交仍执行 `/plan `;键入的 `/计划` 或 `/目标` 在任何语言下的空格与回车裁决中都同样解析,因此在一种语言下写的草稿在另一种语言下照样能提交。贡献项自带 `label`、`description` 与 `icon`,每次生成候选项时读取,语言切换后下一次打开菜单即生效,无需重新注册。 ### 带附件提交 @@ -43,7 +47,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
实现细节——点击展开 -`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。 +`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。`presentation.ts` 持有小节清单、内置行的展示面与本地化 token 的解析;`candidates` 对空查询分小节、对输入的查询经共享的 `rankByName` 排序。
diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 4f4374735b..d135c248d5 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -3,8 +3,10 @@ * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages * consume `register` alone. */ +import type { ComponentType } from 'react' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' /** Copy for an option that must be acknowledged before onSelect can run. */ export interface SelectConfirmation { @@ -31,26 +33,49 @@ export interface SelectOption { * The shell component is owned by ui-commands; business never sees it. Both * callbacks receive the ClientSessionContext captured at popup open. */ -export type CommandUiSpec = { +export interface PopupSelectSpec { readonly kind: 'popupSelect' options(session: ClientSessionContext, signal: AbortSignal): Promise onSelect(option: SelectOption, session: ClientSessionContext): void | Promise } +/** + * Business registration for the action command kind: a bare invocation + * consumes the trigger token and runs one client-side callback (the menu's + * File row opens the composer's file picker). It submits nothing, so an + * attachment-carrying draft never refuses it. + */ +export interface ActionSpec { + readonly kind: 'action' + /** + * Run the action for one session. + * @param session - the ClientSessionContext captured at invocation. + */ + run(session: ClientSessionContext): void +} + +/** The UI behavior of a contribution or decoration. */ +export type CommandUiSpec = PopupSelectSpec | ActionSpec + /** * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host * catalog by name — a collision with a host command fails loud at candidate - * synthesis, never shadows. + * synthesis, never shadows. Row copy is read on every candidate pass, so a + * locale change reaches the next menu open without re-registration. */ export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string - /** Menu row description. */ - readonly description: string + /** Localized menu row title; the name itself when absent. */ + label?(): string + /** Localized menu row description; the row shows none when absent. */ + description?(): string + /** Menu row glyph from the shared icon set. */ + readonly icon?: ComponentType /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean - /** The command's UI behavior (this phase: popupSelect only). */ + /** The command's UI behavior. */ readonly ui: CommandUiSpec } @@ -68,7 +93,7 @@ export interface CommandDecoration { readonly name: string /** Capability filter, called with a fresh projection per bare invocation. */ available(session: ClientSessionContext): boolean - /** The bare-invocation UI (this phase: popupSelect only). */ + /** The bare-invocation UI. */ readonly ui: CommandUiSpec } diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 26e8a32e09..aaa9c8cab7 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { CommandUiRuntime } from './service.ts' import type { PopupSelectInjected } from './PopupSelectView.tsx' import { PopupSelectView } from './PopupSelectView.tsx' @@ -27,9 +28,12 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption, + ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec, + SelectConfirmation, SelectOption, } from './contract.ts' export type { CommandKey } from './locales.ts' +export { SECTION_ROWS, resolveCommandName } from './presentation.ts' +export type { MenuSection } from './presentation.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -39,7 +43,7 @@ declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The popupSelect shell's copy. */ + /** The menu rows' and the popupSelect shell's copy. */ command: CommandKey } } @@ -51,13 +55,33 @@ const NS = 'command' export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale'] /** - * Client plugin body: mount the service, then register the popupSelect shell - * into the input overlay once its declarer is up. + * Client plugin body: mount the service, register the menu's File row (an + * action opening the composer's file picker wherever the composer accepts + * files — never on a subagent), then register the popupSelect shell into the + * input overlay once its declarer is up. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-commands: dictionaries') ctx.plugin(CommandUiRuntime) + const t = ctx.locale.bind(NS) + ctx.inject(['commandUi', 'sessions'], (scope: ClientContext) => { + const command = scope.commandUi + const sessions = scope.get('sessions') as ISessions + scope.effect(() => command.register({ + name: 'file', + label: () => t('label.file'), + icon: IconPaperclipOutline16, + available: session => sessions.subagentAddress(session.sessionId) === undefined, + ui: { + kind: 'action', + run: (session) => { + const actx = sessions.scope(session.sessionId) + if (actx !== undefined) actx.bail(actx, 'slash/input-pick-files') + }, + }, + }), 'ui-commands: File row') + }) ctx.inject(['slots', 'commandUi', 'sessions'], (scope: ClientContext) => { const command = scope.commandUi const sessions = scope.get('sessions') as ISessions diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 384597a9b2..3e1951f6f7 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -1,7 +1,33 @@ -/** `command` namespace dictionaries (the popupSelect shell's copy). */ +/** + * `command` namespace dictionaries: the composer menu's section headings, + * the client face (title, description, claim token) of the built-in Host + * commands whose catalog descriptors carry English text only, the File row's + * title, and the popupSelect shell's copy. + */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'section.add': '添加', + 'section.commands': '指令', + 'label.file': '文件', + 'label.goal': '目标', + 'label.plan': '计划', + 'label.feedback': '反馈', + 'label.compact': '压缩', + 'label.permission': '权限', + 'label.export': '下载日志', + 'description.goal': '设置或查看长期任务的目标', + 'description.plan': '进入或退出计划模式', + 'description.feedback': '记录关于本会话的反馈', + 'description.compact': '压缩较早的对话历史', + 'description.permission': '切换权限预设(沙箱模式与审批策略)', + 'description.export': '将本会话日志下载为 ZIP 压缩包', + 'token.goal': '目标', + 'token.plan': '计划', + 'token.feedback': '反馈', + 'token.compact': '压缩', + 'token.permission': '权限', + 'token.export': '导出', 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -15,8 +41,33 @@ export const zh = { /** The command namespace key union. */ export type CommandKey = keyof typeof zh -/** English dictionary, checked complete against the zh key set. */ +/** + * English dictionary, checked complete against the zh key set. Each + * `description.*` entry is byte-equal to the Host descriptor it localizes: + * that equality is how a catalog row is recognized as the built-in command. + */ export const en = { + 'section.add': 'Add', + 'section.commands': 'Commands', + 'label.file': 'File', + 'label.goal': 'Goal', + 'label.plan': 'Plan', + 'label.feedback': 'Feedback', + 'label.compact': 'Compact', + 'label.permission': 'Permission', + 'label.export': 'Export', + 'description.goal': 'Set or view the goal for a long-running task', + 'description.plan': 'Enter or leave plan mode', + 'description.feedback': 'Record feedback about this session', + 'description.compact': 'Compact older conversation history', + 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'description.export': 'Download this Session log as a ZIP archive', + 'token.goal': 'goal', + 'token.plan': 'plan', + 'token.feedback': 'feedback', + 'token.compact': 'compact', + 'token.permission': 'permission', + 'token.export': 'export', 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts new file mode 100644 index 0000000000..24ad746762 --- /dev/null +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -0,0 +1,132 @@ +/** + * Design-owned presentation of the composer menu (design doc for #3567): + * the two sections with their usage order, and the client face of the + * built-in Host commands — localized title, description, claim token, and + * icon — whose catalog descriptors carry English text only. A catalog row is + * the built-in command when its description equals the canonical English + * text; a scoped override or third-party command of the same name keeps its + * own description and title, and only its section position follows the name. + */ +import type { ComponentType } from 'react' +import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { + IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPlanOutline14, IconSendOutline16, + IconShieldOutline16, +} 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' +import { en, zh } from './locales.ts' +import type { CommandKey } from './locales.ts' + +/** The menu's two sections. */ +export type MenuSection = 'add' | 'commands' + +/** Row names per section, highest usage first; rows outside both lists close the Commands section in catalog order. */ +export const SECTION_ROWS: Readonly> = { + add: ['file', 'goal', 'plan', 'feedback'], + commands: ['compact', 'permission', 'model', 'export'], +} + +/** The dictionary keys and glyph of one built-in Host command's client face. */ +interface HostFace { + readonly label: CommandKey + readonly description: CommandKey + readonly token: CommandKey + readonly icon: ComponentType +} + +/** One built-in Host command's face, keyed by its dictionary entries. */ +function hostFace(name: string, icon: ComponentType): readonly [string, HostFace] { + return [name, { + label: `label.${name}` as CommandKey, + description: `description.${name}` as CommandKey, + token: `token.${name}` as CommandKey, + icon, + }] +} + +/** Built-in Host commands whose client face this package owns. */ +const HOST_FACES: ReadonlyMap = new Map([ + hostFace('goal', IconGoalOutline16), + hostFace('plan', IconPlanOutline14), + hostFace('feedback', IconSendOutline16), + hostFace('compact', IconCompactOutline16), + hostFace('permission', IconShieldOutline16), + hostFace('export', IconDownloadOutline16), +]) + +/** The face of the built-in command a catalog row is, or undefined when the description is not the canonical English text. */ +function builtinFace(name: string, description: string): HostFace | undefined { + const face = HOST_FACES.get(name) + return face !== undefined && en[face.description] === description ? face : undefined +} + +/** + * The localized menu face of a catalog row. + * @param name - catalog command name. + * @param description - the catalog description. + * @param t - the `command` namespace translator. + * @returns title, description, and glyph for a built-in command; undefined + * for any other row, which keeps its catalog description. + */ +export function builtinRowFace( + name: string, + description: string, + t: TranslateNS<'command'>, +): Pick | undefined { + const face = builtinFace(name, description) + return face === undefined ? undefined : { label: t(face.label), description: t(face.description), icon: face.icon } +} + +/** + * The claim token of a catalog row in the current locale (the text the + * composer shows after a pick): the localized token of a built-in command, + * the name itself for any other row. + * @param name - catalog command name. + * @param description - the catalog description. + * @param t - the `command` namespace translator. + * @returns the token without its leading slash. + */ +export function claimToken(name: string, description: string, t: TranslateNS<'command'>): string { + const face = builtinFace(name, description) + return face === undefined ? name : t(face.token) +} + +/** Every localized claim token of every dictionary → the command name it stands for. */ +const TOKEN_ALIASES: ReadonlyMap = new Map( + [...HOST_FACES].flatMap(([name, face]) => + [zh[face.token], en[face.token]].filter(token => token !== name).map(token => [token, name] as const)), +) + +/** + * The command name a typed token stands for: a localized claim token of a + * built-in command in any dictionary resolves to that command, so a draft + * written under one locale still submits under another. + * @param token - the typed name without its leading slash. + * @returns the catalog command name. + */ +export function resolveCommandName(token: string): string { + return TOKEN_ALIASES.get(token) ?? token +} + +/** + * Arrange the empty-query menu: the Add section, then the Commands section, + * each in usage order, with unlisted rows closing Commands in their input + * order; each row carries its section heading. + * @param rows - the visible candidates in catalog-then-contribution order. + * @param t - the `command` namespace translator. + * @returns the sectioned rows. + */ +export function sectionRows(rows: readonly InputTriggerCandidate[], t: TranslateNS<'command'>): readonly InputTriggerCandidate[] { + const listed = new Set([...SECTION_ROWS.add, ...SECTION_ROWS.commands]) + const byName = new Map(rows.map(row => [row.name, row])) + const pick = (names: readonly string[]): InputTriggerCandidate[] => + names.flatMap((name) => { + const row = byName.get(name) + return row === undefined ? [] : [row] + }) + const add = pick(SECTION_ROWS.add).map(row => ({ ...row, section: t('section.add') })) + const commands = [...pick(SECTION_ROWS.commands), ...rows.filter(row => !listed.has(row.name))] + .map(row => ({ ...row, section: t('section.commands') })) + return [...add, ...commands] +} diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index ae7d350e46..c186b439ea 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,10 +2,12 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then position filtering and - * the `/` menu's shared name ranking (ui-primitives `rankByName`); a - * host/contribution name collision fails loud. Every execute - * addresses the session's agent by sessionId — sessions are always + * catalog with contributions by availability, gives built-in Host rows their + * localized face (presentation.ts), then position-filters; an empty query + * lists the Add and Commands sections in usage order, a typed query ranks + * every row by the `/` menu's shared name-and-label ranking (ui-primitives + * `rankByName`). A host/contribution name collision fails loud. Every + * execute addresses the session's agent by sessionId — sessions are always * agent-backed. */ import { Service } from '@deepseek-ai/cordis' @@ -27,6 +29,7 @@ import type { CommandContribution, CommandDecoration, CommandUiContract } from ' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' import { PopupSelectController } from './popup.ts' +import { builtinRowFace, claimToken, resolveCommandName, sectionRows } from './presentation.ts' import type { TokenSegment } from './popup.ts' declare module '@deepseek-ai/cordis' { @@ -185,47 +188,58 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ + /** + * Menu candidates: host catalog + contribution availability, built-in rows + * localized, then position filtering; sections for an empty query, the + * shared name-and-label ranking for a typed one. + */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] const seen = new Set() for (const c of list) { seen.add(c.name) - rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }) + rows.push({ + name: c.name, + ...(builtinRowFace(c.name, c.description, this.t) ?? { description: c.description }), + ...(c.input !== undefined ? { hint: c.input.hint } : {}), + }) } for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`) } - rows.push({ name: contribution.name, description: contribution.description }) + rows.push({ + name: contribution.name, + ...(contribution.label === undefined ? {} : { label: contribution.label() }), + ...(contribution.description === undefined ? {} : { description: contribution.description() }), + ...(contribution.icon === undefined ? {} : { icon: contribution.icon }), + }) } - return rankByName( - rows.filter(c => req.position === 'leading' || c.hint === undefined), - req.query, - ) + const visible = rows.filter(c => req.position === 'leading' || c.hint === undefined) + return req.query === '' ? sectionRows(visible, this.t) : rankByName(visible, req.query) } - /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ + /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(pick.session)) { - this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } const desc = this.directory.resolve(pick.session.sessionId, name) if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss - // A decoration replaces the HOST row's bare invocation with its popup; - // it decorates only a resolvable host command (checked above), never - // manufactures one, and never touches the argument claim below. + // A decoration replaces the HOST row's bare invocation with its popup or + // action; it decorates only a resolvable host command (checked above), + // never manufactures one, and never touches the argument claim below. const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(pick.session)) { - this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } - if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } + if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc.name, desc.description, this.t)) } // Menu-pick execute consumes the trigger span before the detached run // (scoped event; the input owns the CAS guard). this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span }) @@ -236,11 +250,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract { /** Decision table, space column: hot-key sync check; only host leadingInput claims. */ private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined - const name = token.slice(1) - if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space + const name = resolveCommandName(token.slice(1)) + if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined || desc.input === undefined) return undefined - return { claim: this.leadingClaim(desc, session) } + return { claim: this.leadingClaim(desc, session, token.slice(1)) } } /** @@ -250,10 +264,14 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * args-tolerant. * * Envelope policy: an enter submission carrying attachments resolves only - * through a command declaring attachment acceptance. Every other command route — - * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and attachments - * stay in place; nothing executes and nothing is dropped. + * through a command declaring attachment acceptance. Every other submitting + * route — popup, non-accepting claim, bare detached execute — throws the + * refusal so the machine surfaces one composer notice and the draft and + * attachments stay in place; nothing executes and nothing is dropped. An + * action submits nothing and runs regardless. + * + * A typed token is resolved through the localized claim tokens, so a line + * written as `/计划` reaches the `plan` descriptor and executes as `/plan`. */ private async matchEnter( session: ClientSessionContext, @@ -266,16 +284,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const ws = trimmed.search(/\s/) const token = ws === -1 ? trimmed : trimmed.slice(0, ws) const bare = ws === -1 - const name = token.slice(1) + const name = resolveCommandName(token.slice(1)) if (name === '') return undefined + // The Host line always carries the catalog name. + const canonical = `/${name}${trimmed.slice(token.length)}` const refuseAttachments = (): never => { throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, contribution.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments() + this.invoke(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } await this.directory.ensureReady(session.sessionId, signal) @@ -286,42 +306,57 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, decoration.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments() + this.invoke(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } if (desc.input !== undefined) { if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() - return { claim: this.leadingClaim(desc, session) } + return { claim: this.leadingClaim(desc, session, token.slice(1)) } } if (!bare) return undefined if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) - this.runDetached(desc, session, trimmed) + this.runDetached(desc, session, canonical) return 'handled' } - /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */ - private openPopup( + /** + * Invoke one contribution or decoration (menu pick / bare enter): open the + * session's popup, or consume the token and run the action. + */ + private invoke( name: string, ui: CommandContribution['ui'], session: ClientSessionContext, segment: TokenSegment, ): void { + if (ui.kind === 'action') { + this.consumeVia(session.sessionId, segment) + ui.run(session) + return + } const actx = this.scopeFor(session.sessionId) if (actx === undefined) return this.popupFor(actx).open(name, ui, session, segment) } - /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */ - private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim { - const token = `/${desc.name} ` + /** + * Build the leadingInput claim. The composer keeps the claimed token in + * the draft and reads the arguments after it, so the token is the spelling + * the draft will carry: the locale's token for a menu pick, the typed + * spelling for Space and Enter. The command.execute submit transaction + * always sends the catalog name. + */ + private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext, shown: string): CommandClaim { + const token = `/${shown} ` + const line = `/${desc.name} ` return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), ...(desc.input?.attachments === true ? { attachments: true } : {}), - submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), + submit: (args, _actx, attachments) => this.execute(session, line + args, attachments), } } diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index c2143a35c3..eca06cfe44 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -32,8 +32,11 @@ async function bench() { ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id), scopeOf: (c: Context) => scopeOf(c), + subagentAddress: (id: SessionId) => id === sid('child') + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } + : undefined, }) - const commandsRemote = { list: () => Promise.resolve([]) } + const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) } // The service subscribes its cache-invalidation events on construction, so // the Remote face needs `$on` even where this spec dispatches none. ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} }) @@ -73,6 +76,25 @@ describe('apply', () => { expect(slots.entries('conversation.input.overlay')).toHaveLength(0) }) + it('registers the File row: an action that consumes the token and opens the scoped file picker; absent on a subagent', async () => { + const { sources, mint } = await bench() + const source = sources.get('/ command')! + const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal } + const rows = await source.candidates({ sessionId: sid('s1') }, req) + expect(rows).toEqual([{ name: 'file', label: 'File', icon: expect.any(Function) as never, section: 'Add' }]) + expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([]) + const scope = mint('s1') + const picks: string[] = [] + scope.ctx.on('slash/input-consume-token', () => { picks.push('consume'); return true }) + scope.ctx.on('slash/input-pick-files', () => { picks.push('pick-files'); return true }) + const span = { start: 0, end: 5, draftRev: 1 } + expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('s1') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled') + expect(picks).toEqual(['consume', 'pick-files']) + // A session without a live scope runs nothing and throws nothing. + expect(source.onPick({ candidate: rows[0]!, session: { sessionId: sid('gone') }, position: 'leading', via: 'menu', action: 'pick', span })).toBe('handled') + expect(picks).toHaveLength(2) + }) + it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => { const { ctx, slots, mint } = await bench() const command = ctx.get('commandUi') as CommandUiRuntime diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 23ce7ae505..0905a8b59b 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -13,8 +13,9 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types' 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 type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -155,7 +156,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi return source.onPick(pick) } -const themeUi = (over: Partial = {}): CommandUiSpec => ({ +const themeUi = (over: Partial = {}): PopupSelectSpec => ({ kind: 'popupSelect', options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]), onSelect: () => undefined, @@ -164,7 +165,7 @@ const themeUi = (over: Partial = {}): CommandUiSpec => ({ const themeContribution = (over: Partial = {}): CommandContribution => ({ name: 'theme', - description: 'client popup kind', + description: () => 'client popup kind', available: () => true, ui: themeUi(), ...over, @@ -224,7 +225,7 @@ describe('candidates', () => { const { source, listCalls } = await bench() const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) expect(listCalls).toEqual([{ sessionId: sid('s2') }]) - expect(names).toEqual(['plan', 'goal', 'attach']) + expect(names).toEqual(['goal', 'plan', 'attach']) }) it('hides leadingInput commands at inline position', async () => { @@ -238,7 +239,7 @@ describe('candidates', () => { const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1')) command.register(themeContribution({ available })) const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) - expect(s1Names).toEqual(['plan', 'goal', 'theme']) + expect(s1Names).toEqual(['goal', 'plan', 'theme']) expect(available).toHaveBeenLastCalledWith(proj('s1')) const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) expect(s2Names).not.toContain('theme') @@ -257,6 +258,132 @@ describe('candidates', () => { await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') }) + describe('menu presentation (design doc for #3567)', () => { + /** The shipped catalog in Host registration order, with the canonical English descriptions. */ + const SHIPPED: CommandDescriptor[] = [ + { name: 'compact', description: 'Compact older conversation history' }, + { name: 'export', description: 'Download this Session log as a ZIP archive' }, + { name: 'feedback', description: 'Record feedback about this session', input: { hint: '' } }, + { name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '', attachments: true } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, + { name: 'deploy', description: 'third-party command' }, + ] + const Glyph = () => null + const fileContribution = (run = vi.fn()): CommandContribution => ({ + name: 'file', + label: () => 'command:label.file', + icon: Glyph, + available: () => true, + ui: { kind: 'action', run }, + }) + const modelContribution = (): CommandContribution => ({ + name: 'model', + label: () => '模型', + description: () => '选择本会话使用的模型', + icon: Glyph, + available: () => true, + ui: themeUi(), + }) + + it('an empty query lists Add then Commands in usage order; built-in rows carry their localized face, other rows their own text', async () => { + const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(modelContribution()) + command.register(fileContribution()) + const rows = await source.candidates(proj('s1'), req('')) + expect(rows.map(row => row.name)).toEqual([ + 'file', 'goal', 'plan', 'feedback', 'compact', 'permission', 'model', 'export', 'deploy', + ]) + expect(rows.map(row => row.section)).toEqual([ + ...Array(4).fill('command:section.add'), + ...Array(5).fill('command:section.commands'), + ]) + expect(rows[1]).toEqual({ + name: 'goal', + label: 'command:label.goal', + description: 'command:description.goal', + icon: IconGoalOutline16, + hint: '', + section: 'command:section.add', + }) + expect(rows[0]).toEqual({ name: 'file', label: 'command:label.file', icon: Glyph, section: 'command:section.add' }) + expect(rows[6]).toMatchObject({ name: 'model', label: '模型', description: '选择本会话使用的模型', icon: Glyph }) + // A third-party command keeps its catalog text and gets no glyph. + expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' }) + }) + + it('a Host row of a built-in name with another description is not the built-in command', async () => { + const commands: CommandDescriptor[] = [{ name: 'goal', description: 'scoped goal', input: { hint: 'x' } }] + const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) + const [row] = await source.candidates(proj('s1'), req('')) + expect(row).toEqual({ name: 'goal', description: 'scoped goal', hint: 'x', section: 'command:section.add' }) + }) + + it('a typed query ranks rows flat by name or label and drops the section headings', async () => { + const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(modelContribution()) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + await expect(names('模型')).resolves.toEqual(['model']) + await expect(names('label.goal')).resolves.toEqual(['goal']) + await expect(names('ex')).resolves.toEqual(['export']) + // Prefix hits lead; the empty-query section order no longer applies. + await expect(names('pl')).resolves.toEqual(['plan', 'deploy']) + const rows = await source.candidates(proj('s1'), req('pl')) + expect(rows.every(row => row.section === undefined)).toBe(true) + }) + + it('an action contribution: the menu pick consumes the span and runs it; bare enter runs it even with attachments; an argued line misses', async () => { + const run = vi.fn() + const { command, source, mint, warm } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(fileContribution(run)) + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true }) + await warm(proj('s1')) + expect(menuPick(source, 'file', proj('s1'))).toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) + expect(run).toHaveBeenCalledExactlyOnceWith(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/file', new AbortController().signal, { attachments: 2 })).toBe('handled') + expect(consumes[1]).toEqual({ guard: { kind: 'bare-token', token: '/file' } }) + expect(run).toHaveBeenCalledTimes(2) + expect(await source.matchEnter!(proj('s1'), '/file now', new AbortController().signal, { attachments: 0 })).toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/file')).toBeUndefined() + }) + + it('a built-in claim shows the localized token and submits the catalog name', async () => { + const { source, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + await warm(proj('s1')) + const outcome = menuPick(source, 'plan', proj('s1')) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the claim') + expect(outcome.claim.token).toBe('/command:token.plan ') + await outcome.claim.submit('do x', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan do x', images: [] }]) + }) + + it('a typed localized token resolves to the built-in command on space and enter; the Host line carries the catalog name', async () => { + const { source, mint, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + mint('s1') + await warm(proj('s1')) + const space = source.matchSpace!(proj('s1'), '/计划') + if (space === undefined || space === 'handled' || !('claim' in space)) throw new Error('expected the plan claim') + expect(space.claim.hint).toBe('[off|message]') + // The claim keeps the typed spelling (the draft carries it and the + // arguments are read after it); the submission sends the catalog name. + expect(space.claim.token).toBe('/计划 ') + const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 }) + if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim') + expect(enter.claim.attachments).toBe(true) + expect(enter.claim.token).toBe('/目标 ') + await enter.claim.submit('ship it', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }]) + const typed = await source.matchEnter!(proj('s1'), '/plan now', new AbortController().signal, { attachments: 0 }) + if (typed === undefined || typed === 'handled' || !('claim' in typed)) throw new Error('expected the plan claim') + expect(typed.claim.token).toBe('/plan ') + executeCalls.length = 0 + expect(await source.matchEnter!(proj('s1'), '/压缩', new AbortController().signal, { attachments: 0 })).toBe('handled') + await vi.waitFor(() => { expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/compact', images: [] }]) }) + }) + }) }) describe('decorations (bare-invocation UI on host commands)', () => { @@ -271,7 +398,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source } = await bench() command.decorate(goalDecoration()) const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) - expect(names).toEqual(['plan', 'goal']) + expect(names).toEqual(['goal', 'plan']) }) it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d93d7d7dc5..9bed7ed5c9 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: e06270a339a23fa17fb9d51642317fcf2aec6c0f -README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024 +README.md: cefb6f543151b4d52a817ef1539063c4b3207661 +README.zh.md: 385547e06bc90d9a46a2a0b416054efd35acded7 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e06270a339..cefb6f5431 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). +While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no File row, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 315fd3d779..385547e06b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供「文件」菜单项、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index e9659e0022..8dc4ba66e1 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -167,6 +167,12 @@ declare module '@deepseek-ai/cordis' { * @mode bail */ 'slash/input-insert-text'(request: InsertTextRequest): true | undefined + /** + * Open the composer's file picker for the scoped session (the menu's + * File row); true when a mounted composer accepting files opened it. + * @mode bail + */ + 'slash/input-pick-files'(): true | undefined } } @@ -282,6 +288,13 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void + /** + * Bind the composer's file-picker opener for the scoped pick-files event; + * the composer decides inside `open` whether intake is currently allowed. + * @param open - opens the native file dialog. + * @returns the unbind disposer. + */ + bindFilePicker(open: () => void): () => void } /** One independently addressable row projected from the transient queue snapshot. */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 1cacd83812..bd162708b7 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -154,6 +154,8 @@ export class SessionInputShell implements SessionInput { private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined + /** The mounted composer's file-picker opener (scoped pick-files event target). */ + private filePickerFn: (() => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined /** Default sends retained until admission settles or scope disposal releases their attachments. */ @@ -607,6 +609,28 @@ export class SessionInputShell implements SessionInput { } } + /** + * Bind the composer's file-picker opener (ComposerKeyboard face; unbind on unmount). + * @param open - opens the native file dialog. + * @returns the unbind disposer. + */ + bindFilePicker(open: () => void): () => void { + this.filePickerFn = open + return () => { + if (this.filePickerFn === open) this.filePickerFn = undefined + } + } + + /** + * Open the bound file picker (scoped pick-files event listener body). + * @returns whether a composer was bound to open it. + */ + pickFiles(): boolean { + if (this.filePickerFn === undefined) return false + this.filePickerFn() + return true + } + // ---- effect executor ---- /** The claim token the decoration transform styles; null while unclaimed. */ diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index f255877ddb..a0a974855e 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -121,6 +121,7 @@ export class InputHub implements SessionInputResolver { shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), + actx.on('slash/input-pick-files', () => shell.pickFiles() ? true : undefined), ] return () => { for (const off of offs) off() diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 4aeb755983..a8acc30216 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -19,7 +19,7 @@ export const zh = { 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', - 'input.commands': '指令', + 'input.commands': '添加文件或调用指令', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.send.queue': '排队发送', @@ -49,7 +49,6 @@ export const zh = { 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', - 'file.attach': '添加附件', 'file.pending': '待发送文件', 'file.remove': '移除文件 {name}', 'file.uploading': '上传中…', @@ -185,7 +184,7 @@ export const en = { 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', - 'input.commands': 'Commands', + 'input.commands': 'Add files or run commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.send.queue': 'Queue message', @@ -215,7 +214,6 @@ export const en = { 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', - 'file.attach': 'Add attachment', 'file.pending': 'Pending files', 'file.remove': 'Remove file {name}', 'file.uploading': 'Uploading…', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa37b85ecf..0af8bfa831 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, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPlusOutline16, IconWarningOutline16, 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). @@ -266,13 +266,22 @@ export const InputBar = memo(function InputBar({ // registration survives re-renders without re-arming per keystroke. const gate = useRef({ locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, + intakeFiles, uploadsPending, showToast, t, canAcceptDrop, }) gate.current = { locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, + intakeFiles, uploadsPending, showToast, t, canAcceptDrop, } + // The menu's File row opens the same hidden input the drop path feeds, + // under the drop gate the row itself cannot see. + useEffect(() => { + if (keyboard === undefined) return + return keyboard.bindFilePicker(() => { + if (gate.current.canAcceptDrop) fileInputRef.current?.click() + }) + }, [keyboard]) + useEffect(() => { if (editor === null || keyboard === undefined) return return registerComposerKeymap(editor, { @@ -491,18 +500,6 @@ export const InputBar = memo(function InputBar({ - - - { }) it('running continuable subagent keeps Send beside an independent Stop', () => { - const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({ + const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({ running: true, draft: '后续消息', subagent: { @@ -888,9 +888,13 @@ describe('running and lock semantics', () => { expect(button.getAttribute('aria-label')).toBe('排队发送') expect(interruptButton).not.toBeNull() expect(textarea.getAttribute('aria-disabled')).not.toBe('true') - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(true) expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false) + // The menu's File row reaches the same gate: a bound picker stays shut. + const click = vi.spyOn(HTMLInputElement.prototype, 'click') + expect(shell.pickFiles()).toBe(true) + expect(click).not.toHaveBeenCalled() + click.mockRestore() fireEvent.click(button) expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) @@ -923,10 +927,14 @@ describe('running and lock semantics', () => { ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }], ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => { const added = vi.fn(() => null) - const { view, slotCalls } = bench({ ...projection, addFiles: added }) - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false) + const { view, slotCalls, shell } = bench({ ...projection, addFiles: added }) expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(false) expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true) + // The menu's File row opens the hidden input through the bound picker. + const click = vi.spyOn(HTMLInputElement.prototype, 'click') + expect(shell.pickFiles()).toBe(true) + expect(click).toHaveBeenCalledOnce() + click.mockRestore() }) it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { @@ -944,7 +952,7 @@ describe('running and lock semantics', () => { }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect(button.getAttribute('aria-label')).toBe('发送消息') expect(button.disabled).toBe(true) expect(interruptButton?.disabled).toBe(false) @@ -996,7 +1004,7 @@ describe('running and lock semantics', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('会话不可用') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -1129,7 +1137,7 @@ describe('running and lock semantics', () => { expect(editableOf(textarea)).toBe(false) expect(textarea.getAttribute('aria-haspopup')).toBe('menu') expect(textarea.getAttribute('aria-expanded')).toBe('false') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(textarea) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -1432,7 +1440,7 @@ describe('strips and variants', () => { describe('command launcher chrome and control seats', () => { it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('指令')).toBeTruthy() + expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered (render passes may repeat; the @@ -1451,7 +1459,7 @@ describe('command launcher chrome and control seats', () => { const toggleCommandMenu = vi.fn() const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) }) - const launcher = view.getByLabelText('指令') + const launcher = view.getByLabelText('添加文件或调用指令') expect(launcher.getAttribute('aria-expanded')).toBe('false') fireEvent.click(launcher) expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) @@ -1621,7 +1629,7 @@ describe('command launcher chrome and control seats', () => { it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index c333d25ada..7015645633 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -317,7 +317,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 61bfb92d3f..20d3a08cf2 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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-input-trigger/README.md -README.md: 9ad568027e4be8f2e9cc641270cbf6b2f870c59c -README.zh.md: 2926eb87d4738387cf6dc56d38a02f579ed267c4 +README.md: 60c96df61d12b9b3051faafb1a38ec18e5280dbe +README.zh.md: 3f9b35d02367f386497facec1319e4fc12a81921 diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 9ad568027e..60c96df61d 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -25,7 +25,7 @@ This package powers the input trigger pipeline of the Web GUI: it detects `/` an ## Use this package -Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token. +Mount this plugin alongside `ui-conversation`; the menu then appears in the input overlay when the user types a trigger under the caret. Grouped candidates render under title rows, or under the section headings a source attaches to its own rows; a pick routes to the source, and the consuming surface applies the result — a slash command opens its popup or executes, a reference inserts its inline token. A row shows its icon, its title (the candidate `label`, or the `name` when no label is given), the `name` as a trailing alias when the label is not the name in another letter case, and the description right-aligned; a query matches either the name or the label. ### Keyboard and mouse @@ -74,7 +74,6 @@ None; this package neither assembles nor sends a provider request. These limits define the current trigger pipeline. They are current package constraints, not a general menu comparison or a task backlog. - **Global source layer only** — session-scope source registration (per-session shadowing) is designed but not enabled; the ledger tracks the trigger condition, a real per-session source need. -- **`InputTriggerCandidate.icon` renders as text** — `MenuView` drops the string into the icon slot verbatim; wiring to the design-system icon enum lands when that enum ships. - **Overlay SlotMap merge home is split from slot ownership** — the sole `conversation.input.overlay` merge lives here, while ui-conversation owns its anchor, children declaration, and lifecycle because the dependency direction is ui-conversation → ui-input-trigger. diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 2926eb87d4..3f9b35d023 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。 +与 `ui-conversation` 一起挂载本插件;用户在光标处键入触发器时,菜单随即出现在输入浮层中。分组候选项渲染在标题行之下,或渲染在 source 附加在自己各行上的小节标题之下;pick 路由到 source,消费方表面应用其结果——斜杠命令打开其弹窗或执行,引用插入其行内 token。每一行显示图标、标题(候选项的 `label`,没有 label 时显示 `name`)、当标题不是 `name` 的另一种大小写写法时跟在标题后的 `name` 别名,以及右对齐的说明;查询同时匹配 name 与 label。 ### 键盘与鼠标 @@ -74,7 +74,6 @@ kind: "package-reference" 这些限制界定了当前触发流水线。它们是当前包约束,不是通用菜单对比或任务积压。 - **只有全局 source 层**——会话 scope 的 source 注册(逐会话遮蔽)已有设计但未启用;台账记录着触发条件,即真实的逐会话 source 需求。 -- **`InputTriggerCandidate.icon` 以文本渲染**——`MenuView` 把该字符串原样放进图标位;与设计系统图标枚举的接入将在该枚举交付后完成。 - **overlay 的 SlotMap 合并归属与槽位所有权分离**——唯一的 `conversation.input.overlay` 合并放在本包,而 ui-conversation 拥有其锚点、children 声明与生命周期,因为依赖方向是 ui-conversation → ui-input-trigger。 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 948bfac52f..4b9280fd4f 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -86,12 +86,26 @@ white-space: nowrap; } +/* The command name beside a localized title, in the description's ink so + the row reads title first. */ +.itemAlias { + flex: none; + max-width: 20%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-tertiary); +} + +/* Right-aligned so every description ends at the same edge and the titles + read as one left column. */ .itemDescription { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: right; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 64be92b20a..2c0fe990a4 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -7,8 +7,10 @@ * rows only while it has none; pointer picks route back through * the service (combobox pattern — focus never leaves the textarea, so rows * are mousedown-handled and the highlight is exposed via - * aria-activedescendant on the listbox). A source publishing crumbs gets a - * breadcrumb header pinned above the scrolling list. + * aria-activedescendant on the listbox). A row reads title, then the + * command-name alias when the title is not the name in another letter case + * (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb + * header pinned above the scrolling list. */ import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import clsx from 'clsx' @@ -150,10 +152,15 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t > {item.icon !== undefined && ( - + {typeof item.icon === 'string' + ? + : } )} - {item.name} + {item.label ?? item.name} + {item.label !== undefined && item.label.toLowerCase() !== item.name.toLowerCase() && ( + {item.name} + )} {item.description !== undefined && {item.description}} {item.drill === true && ( diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index acbe2a65a3..1ac9d4c4de 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -7,9 +7,11 @@ * never a Cordis context or the mutable Session. RPC and service access go * through the provider plugin's own root context captured at registration. */ +import type { ComponentType } from 'react' import type { PickOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { @@ -40,14 +42,23 @@ export type PickVia = 'menu' | 'space' | 'enter' /** What a pick asks for: resolve the candidate, or drill into it in place. */ export type PickAction = 'pick' | 'drill' -/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */ +/** Leading glyph token of one menu candidate, mapped to its reference SVG by the menu view. */ export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session' /** One menu candidate. Pure display data — zero behavior declaration. */ export interface InputTriggerCandidate { + /** Identity: the pick payload, the exact-match key, and the first search key. */ readonly name: string + /** + * Display title; the name itself when absent. A label that is not the + * name in another letter case renders with the name as a trailing alias, + * and every label is the second search key (a localized title stays + * findable by its command name). + */ + readonly label?: string readonly description?: string - readonly icon?: InputTriggerCandidateIcon + /** Reference glyph token, or an icon component from the shared icon set. */ + readonly icon?: InputTriggerCandidateIcon | ComponentType readonly hint?: string /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */ readonly section?: string diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index f9912ba1f9..13fe143544 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -114,6 +114,26 @@ describe('MenuView', () => { expect(status.children).toHaveLength(2) }) + it('renders a localized label as the title with the name as its alias, an icon component, and the description', () => { + const Glyph = ({ size = 16 }: { size?: number | undefined }) => + mount(openState({ + groups: [{ + source: 'command', + status: 'ready', + items: [ + { name: 'plan', label: '计划', description: '进入或退出计划模式', icon: Glyph, section: '添加' }, + { name: 'file', label: 'File', section: '添加' }, + ], + }], + })) + const options = screen.getAllByRole('option') + expect(options.map(o => o.textContent)).toEqual(['计划plan进入或退出计划模式', 'File']) + expect(options[0]?.querySelector('[data-glyph="plan"]')?.getAttribute('width')).toBe('16') + // A label that is the name in another letter case renders no alias. + expect(options[1]?.querySelectorAll('span')).toHaveLength(1) + expect(screen.getAllByText('添加')).toHaveLength(1) + }) + it('keeps an opted-out source title hidden while its candidates are pending', () => { mount(openState({ groups: [{ source: 'reference', showGroupTitle: false, status: 'pending', items: [] }], diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index dd7d4a89fd..01a7f227bb 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -22,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ModelDirectoryState } from './directory.ts' import { ModelDirectoryResolver } from './service.ts' import type { ModelSelectInjected } from './slots.ts' @@ -120,16 +121,16 @@ export function apply(ctx: ClientContext): void { // a locale change reaches the next publish. ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') }) - // Entry 1: the /model popupSelect over the shared directory. The command - // description is registry-held text: it reads t() once at registration and - // refreshes only on re-registration, not on locale change. + // Entry 1: the /model popupSelect over the shared directory. ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { const command = scope.get('commandUi') as CommandUiContract const models = scope.modelDirectories const sessions = scope.sessions scope.effect(() => command.register({ name: 'model', - description: t('command.description'), + label: () => t('command.label'), + description: () => t('command.description'), + icon: IconDataOutline16, available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 3bf0774679..7a6a1b7d12 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', 'trigger.fallback': '选择模型', @@ -35,6 +36,7 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', 'trigger.fallback': 'Select model', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 1bf11733b5..7ffec18497 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -16,7 +16,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types' -import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' +import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ModelSelectInjected } from '../src/client/slots.ts' import { apply, inject } from '../src/client/index.ts' import { zh } from '../src/client/locales.ts' @@ -157,6 +157,11 @@ async function bench() { return { ctx, fiber, mint, calls, remote, contribution: () => contribution!, + popup: (): PopupSelectSpec => { + const ui = contribution!.ui + if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind') + return ui + }, seat: () => seats.get('conversation.input.model')!, hostCurrent: () => selected, setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, @@ -182,7 +187,7 @@ describe('ui-model-selection dual entry', () => { it('popup options mark the host current active with the provider group in the detail', async () => { const b = await bench() b.mint('s1') - const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + const options = await b.popup().options(projection('s1'), new AbortController().signal) expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro']) expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' }) expect(options[1]?.active).toBeUndefined() @@ -209,7 +214,7 @@ describe('ui-model-selection dual entry', () => { reasoningEffort: 'max', }) // The POPUP's next options pass reflects it without a seat-side reload. - const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + const options = await b.popup().options(projection('s1'), new AbortController().signal) expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true }) }) @@ -217,9 +222,9 @@ describe('ui-model-selection dual entry', () => { const b = await bench() b.mint('s1') const seatFace = b.seat().inject!(sid('s1')) - const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + const options = await b.popup().options(projection('s1'), new AbortController().signal) const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')! - await b.contribution().ui.onSelect(pro, projection('s1')) + await b.popup().onSelect(pro, projection('s1')) expect(seatFace.directory.getSnapshot().current).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-pro', @@ -239,8 +244,8 @@ describe('ui-model-selection dual entry', () => { // The service face resolves the same instance the seat inject handed out. expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory) await Promise.all([ - b.contribution().ui.options(projection('a'), new AbortController().signal), - b.contribution().ui.options(projection('b'), new AbortController().signal), + b.popup().options(projection('a'), new AbortController().signal), + b.popup().options(projection('b'), new AbortController().signal), ]) expect(b.calls.models).toBe(1) }) @@ -369,7 +374,7 @@ describe('ui-model-selection dual entry', () => { b.address(sid('child')) expect(b.contribution().available(projection('child'))).toBe(false) - await expect(b.contribution().ui.options( + await expect(b.popup().options( projection('child'), new AbortController().signal, )).rejects.toThrow(/unavailable for addressed subagent/) diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index e6f9cd39f8..f802b93b2d 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -15,7 +15,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client' +import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' import { PermissionRow, type PermissionRowInjected, @@ -82,6 +82,11 @@ async function bench() { ctx, fiber, locale, values, commands, remote, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, + popup: (): PopupSelectSpec => { + const ui = decoration!.ui + if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind') + return ui + }, permissionRow: () => ctx.slots.entries('settings.general.item') .find(entry => entry.component === PermissionRow), } @@ -110,11 +115,11 @@ describe('ui-permission browser plugin', () => { expect(c.available(proj)).toBe(false) b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' }) expect(c.available(proj)).toBe(true) - const options = await c.ui.options(proj, new AbortController().signal) + const options = await b.popup().options(proj, new AbortController().signal) expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access']) expect(options.every(option => option.active !== true)).toBe(true) b.values.set(sid('s1'), SELECT) - const again = await c.ui.options(proj, new AbortController().signal) + const again = await b.popup().options(proj, new AbortController().signal) expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // English built-ins use product labels; other kebab-case names title-case. @@ -127,7 +132,7 @@ describe('ui-permission browser plugin', () => { confirmLabel: 'Enable Full access', }) b.locale.setLocale('zh') - const localized = await c.ui.options(proj, new AbortController().signal) + const localized = await b.popup().options(proj, new AbortController().signal) expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限']) expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ title: '确认启用完全权限?', @@ -143,28 +148,27 @@ describe('ui-permission browser plugin', () => { { value: '__proto__', name: '__proto__' }, { value: 'plain', name: 'Ask Every Time' }, ] }) - const passthrough = await c.ui.options(proj, new AbortController().signal) + const passthrough = await b.popup().options(proj, new AbortController().signal) expect(passthrough.map(option => option.label)).toEqual([ 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time', ]) // A projection that vanished between availability and open throws. - expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal)) + expect(() => b.popup().options({ sessionId: sid('ghost') }, new AbortController().signal)) .toThrow(/not available on this host/) }) it('a pick submits the /permission line; rejection and unmatched throw', async () => { const b = await bench() - const c = b.decoration()! const proj = { sessionId: sid('s1') } b.values.set(sid('s1'), SELECT) - await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj) + await b.popup().onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj) expect(b.commands).toEqual(['/permission danger-full-access']) b.setResult({ ok: false }) - await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/) + await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/) b.setResult({ ok: true, matched: false }) - await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/) + await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/) // An unmaterialized session throws before any submit. - await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') })) + await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') })) .rejects.toThrow(/not materialized/) }) diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts index 7dc3b3a0cc..2f7bdd880f 100644 --- a/packages/client/ui-primitives/src/rank-by-name.ts +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -1,8 +1,9 @@ /** * Shared ranking for `/` menu candidates: the query must be a - * case-insensitive ordered subsequence of the candidate name. Prefix hits - * rank first, then the strongest alignment score, then the source order of - * the input. Decision record: + * case-insensitive ordered subsequence of the candidate name or, when the + * candidate carries one, of its display label (a localized title). Prefix + * hits rank first, then the strongest alignment score over either key, then + * the source order of the input. Decision record: * .agents/notes/archived/feature/2026-08-04-web-slash-command-fuzzy-discovery.md */ @@ -59,19 +60,32 @@ function alignmentScore(name: string, query: string): number | undefined { /** * Rank named items by a menu query. - * @param items - candidates in source order (a host catalog, then client contributions). + * @param items - candidates in source order (a host catalog, then client + * contributions); an item's optional `label` is a second search key beside + * its name. * @param rawQuery - the text typed after the trigger, matched case-insensitively. * @returns the matching items: prefix hits first, then by alignment score, * then in source order. The input list itself for an empty query. */ -export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { +export function rankByName( + items: readonly T[], + rawQuery: string, +): readonly T[] { const query = rawQuery.toLowerCase() if (query === '') return items const ranked: Ranked[] = [] items.forEach((item, index) => { - const name = item.name.toLowerCase() - const score = alignmentScore(name, query) - if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + const keys = item.label === undefined ? [item.name] : [item.name, item.label] + let prefix = false + let score: number | undefined + for (const key of keys) { + const lower = key.toLowerCase() + const keyScore = alignmentScore(lower, query) + if (keyScore === undefined) continue + prefix ||= lower.startsWith(query) + score = score === undefined ? keyScore : Math.max(score, keyScore) + } + if (score !== undefined) ranked.push({ item, index, prefix, score }) }) ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts index 89ed5c7733..027fbb4958 100644 --- a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -34,6 +34,20 @@ describe('rankByName', () => { expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) }) + it('matches the display label as a second key and keeps the stronger alignment', () => { + const items = [ + { name: 'goal', label: '目标' }, + { name: 'plan', label: '计划' }, + { name: 'permission', label: '权限' }, + ] + expect(names(rankByName(items, '目标'))).toEqual(['goal']) + expect(names(rankByName(items, 'goal'))).toEqual(['goal']) + expect(names(rankByName(items, 'p'))).toEqual(['plan', 'permission']) + expect(names(rankByName(items, '划'))).toEqual(['plan']) + // A label prefix hit ranks like a name prefix hit. + expect(names(rankByName([{ name: 'xplan', label: 'plan' }, { name: 'plant' }], 'pla'))).toEqual(['xplan', 'plant']) + }) + it('returns the ranked items with their payload intact', () => { const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index ef840fe9c8..771b8c6f69 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -60,7 +60,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { export function apply(ctx: Context): void { ctx.commands.register({ name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, recordInput: false, handler: executeFeedbackCommand, diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index e59020bfc6..f9669bbf44 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -89,7 +89,7 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, }) expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false }) diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index b9e64ee7f2..4251ea72a2 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -189,7 +189,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', - description: 'set or view the goal for a long-running task', + description: 'Set or view the goal for a long-running task', input: { hint: '[|clear|edit |pause|resume]', attachments: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1c1761cf74..a8728b0ed1 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', - description: 'set or view the goal for a long-running task', + description: 'Set or view the goal for a long-running task', input: { hint: '[|clear|edit |pause|resume]', attachments: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 4af682fdb2..e41e520238 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -29,9 +29,7 @@ - img - text: "Failed Bash Error: tool call aborted before dispatch" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index aa741273ff..3d9a16da72 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -117,9 +117,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 6939707ba4..053c709e1f 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -47,9 +47,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 75bd8bad1b..885b063af6 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -39,9 +39,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 6939707ba4..053c709e1f 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -47,9 +47,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 75bd8bad1b..885b063af6 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -39,9 +39,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index cbddcb3d63..676caf3bd4 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -41,9 +41,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high": diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 0d877cc2e6..45a27897bf 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -51,9 +51,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 61079e8d61..08dedf0100 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index c88d6d3c40..5783ef6a23 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -229,9 +229,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index bcd881ffef..1d731cceaa 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -117,9 +117,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md b/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md index 13d915959d..96bf4bd119 100644 --- a/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md +++ b/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md @@ -1,3 +1,3 @@ - listbox "Trigger suggestions": - text: Commands - - option "compact Compact older conversation history" [selected] + - option "Compact Compact older conversation history" [selected] diff --git a/snapshots/web/lifecycle-chrome/command-menu.expected.md b/snapshots/web/lifecycle-chrome/command-menu.expected.md index ed7e837f3f..c720fee9e1 100644 --- a/snapshots/web/lifecycle-chrome/command-menu.expected.md +++ b/snapshots/web/lifecycle-chrome/command-menu.expected.md @@ -1,9 +1,11 @@ - listbox "Trigger suggestions": + - text: Add + - option "File" [selected] + - option "Goal Set or view the goal for a long-running task" + - option "Plan Enter or leave plan mode" + - option "Feedback Record feedback about this session" - text: Commands - - option "compact Compact older conversation history" [selected] - - option "export Download this Session log as a ZIP archive" - - option "feedback record feedback about this session" - - option "goal set or view the goal for a long-running task" - - option "permission Switch the permission preset (sandbox mode + approval policy)" - - option "plan Enter or leave plan mode" - - option "model Select the model for this conversation" + - option "Compact Compact older conversation history" + - option "Permission Switch the permission preset (sandbox mode + approval policy)" + - option "Model Select the model for this conversation" + - option "Export Download this Session log as a ZIP archive" diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index aa7c6a7728..7c5a8b7f98 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -31,9 +31,7 @@ - img - textbox "Describe what you want to build, / commands, @ files or sessions": - paragraph -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index bda1b957d8..da4ae2482b 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -30,9 +30,7 @@ - text: Standard mode - img - textbox "Describe what you want to build, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index f7e902be44..eb812c4193 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -43,9 +43,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 47c78b4156..e44a7f6a62 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 906f3a9ed3..cbf5e8af58 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -40,9 +40,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index d50dc2c162..5529b89440 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -36,9 +36,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index d6cd35dcb3..6df10863c4 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -26,9 +26,7 @@ - text: This turn failedAPI key is invalid - code: AUTH - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 3dfa3da484..4cb2835f8a 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -25,9 +25,7 @@ - paragraph: partial - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index a9c40b178e..3995103b95 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -28,9 +28,7 @@ - text: This turn failedupstream 503 - code: SERVER - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 6c3591cc67..4c959a711a 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -45,9 +45,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 21ce478e78..996c067e9e 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 505805bc35..b0427febf7 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -26,9 +26,7 @@ - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 58ae5e04f7..37360d44eb 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -65,9 +65,7 @@ - text: Ran for {{duration}} - text: 7/25 {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 9ad6fdf256..214dae5e98 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -40,9 +40,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 43c4b97487..4ce1ce35b0 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -61,9 +61,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index c04fb13bad..2cd2060efa 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -41,9 +41,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 94727a92fe..c97a610ec9 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -62,9 +62,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index a57598a5c3..55705b01be 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -53,9 +53,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 7d7da25ef6..d8aa619f77 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index d23da91aed..344aaacc60 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -32,9 +32,7 @@ - listitem: Which color do you prefer? - button "Inspect" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 6645c331fd..fad1b9caa0 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -26,9 +26,7 @@ - status: Deep diving... - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index d0586fb967..be5272ae75 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -42,9 +42,7 @@ - button "Cancel editing": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/failed.expected.md b/snapshots/web/queue-actions/failed.expected.md index 02c1fa881b..044a159013 100644 --- a/snapshots/web/queue-actions/failed.expected.md +++ b/snapshots/web/queue-actions/failed.expected.md @@ -1,9 +1,7 @@ - alert: Queue submission failed (session/agent-busy) - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue submission to retry -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 79f239edd8..586db15583 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -42,9 +42,7 @@ - img - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 6e9d8c1134..1bb442434c 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -59,9 +59,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index a4547ed17d..ed0b3e98d0 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -55,9 +55,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index d2b2a0d9de..1bfaba53f8 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -35,9 +35,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index e7aa55934d..4cbf81a164 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -35,9 +35,7 @@ - button "Steer queued message": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 7ea64dd0f6..da192e1ff5 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -79,9 +79,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 68d4ef90b7..13652ebae5 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -35,9 +35,7 @@ - button "Steer queued message": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 527ad8658a..56d3886be4 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -61,9 +61,7 @@ - img - text: permission preset read-only - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index ba2ac9fd6b..ff6f5e58be 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -65,9 +65,7 @@ - text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index b655c75182..affe391552 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -59,9 +59,7 @@ - img - text: Context injection AGENTS.md - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 90642e4c58..7590043fe8 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -41,9 +41,7 @@ - img - text: Context injection AGENTS.md - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 737d74f2a6..9ab9a4c0ca 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -54,9 +54,7 @@ - text: Ran for {{duration}} - text: {{date}} {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index fc4fa021e5..8eed39301f 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -46,9 +46,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 0ff456ca8a..9d1e0d15a5 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -38,9 +38,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 92e5e2c31a..a4ce835db9 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -73,9 +73,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands": - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index ec7b77fe58..181af16fe0 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -61,9 +61,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands": - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index e7f7483bc3..3d7e5e2db6 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -36,9 +36,7 @@ - button "Steer queued message": - img - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] -- button "Commands" [disabled]: - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands" [disabled]: - img - 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 4f9b0b72f3..c108cda33d 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -38,9 +38,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 2108bafd30..6cb8a8c450 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -51,9 +51,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index d4fced7fe3..a76a077206 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -35,9 +35,7 @@ - paragraph: partial - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index fd6de1de07..57a4efebcf 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -37,9 +37,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 916569dc9c..20d2aaba79 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -38,9 +38,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 1aa9b33fc1..64841a676e 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -43,9 +43,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": From c75e100893084b6d999e8e5cdc3eb62ebce5d310 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 12:47:21 +0800 Subject: [PATCH 03/41] fix(client): share the shield contour and describe the menu glyphs by shape The permission selector and IconShieldOutline16 now read one exported SHIELD_OUTLINE_PATH; the icon-set header no longer claims every glyph is filled; the three menu glyph docs name what they draw and where they are used; the compact ring matches the set's extent; the icon pin asserts the three names. --- .../src/client/skeleton/PermissionSelect.tsx | 17 +++---- .../client/ui-primitives/src/icons/index.tsx | 44 +++++++++++++------ .../ui-primitives/tests/icons.client.spec.tsx | 8 ++++ 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index b54049efef..0a63c4cca8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission-presets/client' -import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconChevronDownOutline14, Menu, RiskConfirmation, SHIELD_OUTLINE_PATH, SHIELD_OUTLINE_STROKE, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import { en } from '../locales.ts' @@ -10,16 +12,15 @@ import css from './PermissionSelect.module.css' const FULL_ACCESS = 'danger-full-access' -/* Shield glyphs (design set 1556): check = read-only, pencil = workspace - write, exclamation = full access. currentColor so the trigger and menu - rows tint them with their own text color. */ - -const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' +/* 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. */ const permissionGlyphs = new Map([ ['read-only', ( - + )], @@ -34,7 +35,7 @@ const permissionGlyphs = new Map([ )], [FULL_ACCESS, ( - + diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 47682cfab3..5679a6aebe 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1,7 +1,8 @@ /** - * ic_ds_* icon set for the dsh web UI. All glyphs render fill="currentColor" - * and take {size, className}. Batch A mirrors the deepsuite icon library - * (same figma source); batch B glyphs are harness-only figma extracts. + * ic_ds_* icon set for the dsh web UI. All glyphs ride currentColor (filled + * or stroked) and take {size, className}. Batch A mirrors the deepsuite icon + * library (same figma source); batch B glyphs are harness-only figma + * extracts; the product glyphs at the end are hand-authored. */ import type { IconProps } from './props.ts' @@ -935,7 +936,12 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => ( ) -/** plan_outline_14 (design doc for #3567: the composer menu's Plan row glyph; drawn on a 14 grid) */ +/** + * Plan row glyph of the composer menu (#3567): a document with three ruled + * lines and a pencil over its corner. Drawn on a 14 grid whose content spans + * 0.5..13.5, so at the menu's 16px render it fills the same extent as the + * 16-grid glyphs beside it. + */ export const IconPlanOutline14 = ({ size = 14, className }: IconProps) => ( @@ -953,22 +959,32 @@ export const IconPlanOutline14 = ({ size = 14, className }: IconProps) => ( ) -/** compact_outline_16 (the composer's context-usage ring as a static glyph: a quiet track with one filled arc) */ +/** + * Compact row glyph of the composer menu: the composer's context-usage ring + * (ContextMeter) frozen at its resting look — a quiet track with one filled + * quarter arc. A restyle of the live ring revisits this copy. + */ export const IconCompactOutline16 = ({ size = 16, className }: IconProps) => ( - - + + ) -/** shield_outline_16 (design set 1556: the permission shield contour without a mode mark) */ +/** + * The permission shield contour on the 16 grid (design set 1556), stroked at + * {@link SHIELD_OUTLINE_STROKE}. The composer's permission selector composes + * its mode marks (check, pencil, exclamation) over this same path inside one + * svg, so the geometry lives here once. + */ +export const SHIELD_OUTLINE_PATH = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' + +/** Stroke width of {@link SHIELD_OUTLINE_PATH}. */ +export const SHIELD_OUTLINE_STROKE = '1.31831' + +/** Permission row glyph of the composer menu: the shield contour alone, without a mode mark. */ export const IconShieldOutline16 = ({ size = 16, className }: IconProps) => ( - + ) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 4cae7397ed..d8b566704a 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -19,6 +19,14 @@ const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => { expect(iconNames.length).toBe(78) + // The composer menu's own glyphs, pinned by name. + expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16'])) + }) + + it('the permission selector composes its marks over the shield contour exported here', () => { + const { container } = render() + expect(container.querySelector('path')?.getAttribute('d')).toBe(primitives.SHIELD_OUTLINE_PATH) + expect(container.querySelector('path')?.getAttribute('stroke-width')).toBe(primitives.SHIELD_OUTLINE_STROKE) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { From 00c2c47e19f6f5af388712c0c6b75447e99fc860 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 12:52:09 +0800 Subject: [PATCH 04/41] fix(web): key the claim hint on the command name, not the claim token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A localized claim token (/目标 ) made the composer look up hint.目标, miss the dictionary, and fall back to the Host's usage string. CommandClaim now carries the catalog name beside the display token, the state projection keeps it, and the hint lookup reads it. --- .../client/ui-commands/src/client/service.ts | 1 + .../ui-commands/tests/service.client.spec.ts | 1 + .../src/client/contract/input.ts | 5 ++++- .../src/client/input/machine.ts | 1 + .../src/client/skeleton/InputBar.tsx | 3 +-- .../tests/input-bar.client.spec.tsx | 19 ++++++++++++++++--- .../tests/input-matrix.client.spec.tsx | 6 +++--- .../tests/input-scenarios.client.spec.tsx | 1 + .../tests/submit-machine.client.spec.ts | 1 + .../tests/service.client.spec.ts | 2 +- 10 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index c186b439ea..bf5e992c81 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -353,6 +353,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const token = `/${shown} ` const line = `/${desc.name} ` return { + name: desc.name, token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), ...(desc.input?.attachments === true ? { attachments: true } : {}), diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 5ee855f9db..526f0efdaf 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -403,6 +403,7 @@ describe('candidates', () => { // The claim keeps the typed spelling (the draft carries it and the // arguments are read after it); the submission sends the catalog name. expect(space.claim.token).toBe('/计划 ') + expect(space.claim.name).toBe('plan') const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 }) if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim') expect(enter.claim.attachments).toBe(true) diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 8dc4ba66e1..d050533950 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -43,6 +43,9 @@ export interface SubmitOutcome { /** Command-mode credential supplied by one input-trigger source. */ export interface CommandClaim { + /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ + readonly name: string + /** The text the draft carries for the claim (`/name ` in English, a localized token elsewhere). */ readonly token: string readonly hint?: string readonly attachments?: boolean @@ -348,7 +351,7 @@ export interface InputState { readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean } + readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Read-only transient inbox projection from Session control, including pending steering. */ diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 899da6f996..81aa519346 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -53,6 +53,7 @@ export class SubmitMachine { ...(c ? { claim: { + name: c.name, token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}), ...(c.attachments === true ? { attachments: true } : {}), diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f669c491d3..31bde384f0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -391,8 +391,7 @@ export const InputBar = memo(function InputBar({ : null const hint = ((): string | null => { if (rawHint === null) return null - // Claim tokens have the `/name ` format (trailing space); trim to the bare name. - const commandName = input?.claim?.token.slice(1).trim() ?? '' + const commandName = input?.claim?.name ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the // dictionary and keep the machine's own hint, so the call is wide. diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f1b95dc84d..9275745e61 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -803,7 +803,7 @@ describe('running and lock semantics', () => { act(() => { claimed.shell.setDraft('/goal ') claimed.shell.beginCommand( - { token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) }, + { name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) }, { start: 0, end: 6, draftRev: claimed.shell.snapshot.draftRev }, ) }) @@ -1201,6 +1201,7 @@ describe('machine pending lock', () => { shell.setDraft('/goal ') shell.beginCommand( { + name: 'goal', token: '/goal ', submit: () => new Promise(() => {}), // never settles: stays submitting }, @@ -1227,7 +1228,7 @@ describe('decorations', () => { act(() => { shell.setDraft('/goal ') shell.beginCommand( - { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh @@ -1246,13 +1247,25 @@ describe('decorations', () => { act(() => { shell.setDraft('/goal ') shell.beginCommand( - { token: '/goal ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { name: 'goal', token: '/goal ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) }) expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) }) + it('the hint lookup keys on the claim name, so a localized claim token keeps the locale entry', () => { + const { shell, textarea } = bench() + act(() => { + shell.setDraft('/目标 ') + shell.beginCommand( + { name: 'goal', token: '/目标 ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { start: 0, end: 4, draftRev: shell.snapshot.draftRev }, + ) + }) + expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) + }) + it('an inserted reference renders a real chip capsule with its icon and label', () => { const { view, shell } = bench() const reference = { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 5533c236a5..67e0b6895c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -111,7 +111,7 @@ function bench(over?: { shell.setDraft(token) shell.beginCommand( { - token, hint, + name: token.slice(1).trim(), token, hint, ...(attachments === true ? { attachments: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, @@ -141,7 +141,7 @@ describe('matrix row: claimed', () => { const { view, textarea, shell, claim } = bench() claim() act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' }) + expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' }) expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ') // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior). expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) @@ -201,7 +201,7 @@ describe('matrix row: claimed with attachments', () => { const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) }) claim('/goal ', '目标', true) // The claim currency carries the acceptance flag the pre-gate reads. - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true }) + expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标', attachments: true }) act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 88ab3a809f..226671eaa4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -58,6 +58,7 @@ function commandSource( ) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ + name: desc.name, token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), ...(desc.input?.attachments === true ? { attachments: true } : {}), diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 73d16d0235..563079ef9e 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -13,6 +13,7 @@ import { scanTextRefs } from '../src/client/input/decorations.ts' function claimOf(name: string, hint?: string): CommandClaim { return { + name, token: `/${name} `, ...(hint !== undefined ? { hint } : {}), submit: async () => ({ kind: 'success' }), diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 563d5f8a92..640ff0d8c2 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -63,7 +63,7 @@ function readySource( } const claimOf = (token: string): CommandClaim => - ({ token, submit: () => Promise.resolve({ kind: 'success' }) }) + ({ name: token.slice(1).trim(), token, submit: () => Promise.resolve({ kind: 'success' }) }) /** One microtask hop: lets settled candidate promises flow into the store. */ const tick = () => Promise.resolve() From a4954e1bd2c41c2ffe9fb4baff171aa1c4d93908 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 12:58:11 +0800 Subject: [PATCH 05/41] test(web): follow the renamed launcher and capitalized rows in CI gates Registers slash/input-pick-files with the cordis catalog walk exemptions beside its sibling slash-input events, and points the web e2e at the 'Add files or run commands' launcher and the capitalized row titles the paperclip removal and the menu presentation introduced. --- apps/web/tests/agent-preset-selection.e2e.ts | 5 +++-- apps/web/tests/feedback-command.e2e.ts | 2 +- apps/web/tests/navigation-panes.e2e.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 2 +- scripts/gen-cordis-catalog.ts | 1 + 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 8f1bf2033a..8207a98261 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -312,7 +312,8 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) - const onMinimal = await menuOptions(page) + // Rows read as `Title Description`; the title is the capitalized command name. + const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase()) expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) // Preset-scoped commands follow the switch; the client's own model command @@ -332,7 +333,7 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) - const onStandard = await menuOptions(page) + const onStandard = (await menuOptions(page)).map(option => option.toLowerCase()) expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index a2946223dc..7eabc3502f 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -82,7 +82,7 @@ describe('web e2e: /feedback command acknowledgement', () => { await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1) await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') - await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..32def9e0a3 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -335,7 +335,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const input = page.locator('[data-composer-input]').first() const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await input.fill('/export') - await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) + await page.getByRole('option', { name: /^Export/u }).waitFor({ timeout: 10_000 }) await input.press('Enter') const slashDownload = await slashDownloadPromise expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 84096583f9..6dd59379c1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -506,7 +506,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // command/done can arrive before the submit reply releases the composer. await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') - await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') .split(userId).join('{{userId}}') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f1c1462d2f..cafca07464 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -233,6 +233,7 @@ export const EVENT_WALK_EXEMPTIONS: Record = { 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', + 'slash/input-pick-files': 'client-face slash-input protocol — packages/client/ui-input-trigger/README.md owns the API', 'slots/changed': 'client-face slot invalidation signal — packages/client/ui-renderer/README.md owns the API', 'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the API', } From eff482c46b8df11eb2baa26e807ba3fe5629bf91 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 14:27:51 +0800 Subject: [PATCH 06/41] fix(web): stabilize composer command editing and scrolling --- ...menu-sections-and-localized-rows.i18n.yaml | 4 +- ...mposer-menu-sections-and-localized-rows.md | 6 ++ ...ser-menu-sections-and-localized-rows.zh.md | 6 ++ apps/web/tests/lifecycle-chrome.e2e.ts | 62 ++++++++++++++++ apps/web/tests/sidebar-scrollbar.e2e.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/input.ts | 2 +- .../src/client/input/editor/claim-decor.ts | 3 +- .../src/client/input/editor/keymap.ts | 16 +++++ .../src/client/input/editor/text-ref.ts | 2 +- .../src/client/input/machine.ts | 11 ++- .../src/client/skeleton/InputBar.module.css | 8 +++ .../tests/input-bar.client.spec.tsx | 31 ++++++++ .../tests/input-scenarios.client.spec.tsx | 29 ++++++++ .../tests/keymap-routing.client.spec.tsx | 35 +++++++++- .../tests/lexical-editor-core.client.spec.tsx | 10 +-- .../tests/submit-machine.client.spec.ts | 29 +++++++- .../src/client/MenuView.module.css | 25 ++++++- .../ui-input-trigger/src/client/MenuView.tsx | 26 +++++-- .../tests/menu-view.client.spec.tsx | 19 ++++- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../client/ui-theme/src/styles/scrollbar.css | 19 +++-- .../tests/scrollbar-styles.client.spec.ts | 70 ++++++++++--------- 27 files changed, 359 insertions(+), 72 deletions(-) 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 fd023c5ddc..af47bdd3ec 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: c8fc2226a15828de14f623fc582473e72e3ec1f1 -2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 5905c872ab583294aabf15a1b69f2dfe1312e4ba +2026-09-08-composer-menu-sections-and-localized-rows.md: ab381d2e3ab62cc272a3984468163a657b717da1 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 8fe15119ac96ac7d1aac6206a7c423ef35d72fd2 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 c8fc2226a1..ab381d2e3a 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 @@ -20,8 +20,14 @@ The File row is an `action` contribution, a new `CommandUiSpec` kind whose bare `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. +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. + ## Alternatives considered +**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation. + +**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window. + **Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; the client already owns product copy for every other surface (the locale-owned client UI copy decision), and the equality guard keeps a scoped or third-party descriptor verbatim. **Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete. 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 5905c872ab..8fe15119ac 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 @@ -20,8 +20,14 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列 `ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 + ## 考虑过的替代方案 +**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。 + +**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。 + **在宿主侧本地化说明。** 宿主没有语言设置,目录又被所有客户端共享;客户端已经拥有其他所有界面的产品文案(客户端界面文案归语言所有的决策),相等性判断还能让作用域覆盖或第三方描述符原样保留。 **输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 07a5095eba..1ebdb03002 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -133,6 +133,68 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) + it.skipIf(MODE === 'record').each([ + { locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' }, + { locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' }, + { locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' }, + { locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' }, + ])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => { + const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale }) + const inputTripwire = watchConsole(inputPage) + onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`)) + try { + await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const input = inputPage.locator('[data-composer-input]').first() + await writeComposerDraft(inputPage, input, '/') + await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click() + await expect.poll(() => input.textContent()).toBe(`${token} `) + await input.press('End') + await inputPage.keyboard.insertText('这是任务') + await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`) + for (let i = 0; i < 5; i++) await input.press('Backspace') + const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent() + await expect.poll(() => input.textContent()).toBe(token) + await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') + await expect.poll(tokenText).toBe(token) + await input.press('Space') + await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') + await expect.poll(() => input.textContent()).toBe(`${token} `) + await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token) + const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content) + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + const cdp = await inputPage.context().newCDPSession(inputPage) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.insertText', { text: '这' }) + await expect.poll(() => input.textContent()).toBe(`${token} 这`) + await expect.poll(shownHint).toBe('none') + await input.press('Backspace') + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 }) + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + await input.press('Backspace') + await input.press('Backspace') + await expect.poll(() => input.getAttribute('data-phase')).toBe('plain') + await writeComposerDraft(inputPage, input, '') + const placeholder = inputPage.locator('[data-composer-placeholder]').first() + await expect.poll(() => placeholder.isVisible()).toBe(true) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(() => placeholder.isVisible()).toBe(false) + await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 }) + await expect.poll(() => placeholder.isVisible()).toBe(true) + await cdp.detach() + expect(inputTripwire.pageErrors).toEqual([]) + expect(inputTripwire.warnings).toEqual([]) + } finally { + await inputPage.close() + } + }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 0f1443c8d9..b8d8d36ebe 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -83,7 +83,7 @@ function measureList(page: Page): Promise { }) .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover') - .map(rule => rule.style.getPropertyValue('background')) + .map(rule => rule.style.getPropertyValue('background-color')) const style = getComputedStyle(list) const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6346973dc8..195b7fb645 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: 4793373df94e2b7c617c1f44dd74d2b32bd86d6f -README.zh.md: 463b9bff010fee7d29cad46828dc1caff8b22fff +README.md: 6a953a0f099d5205da683d81a1390a05b5159209 +README.zh.md: 612a7ca72787a4199207ad5fd0ac6846024e1d6e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4793373df9..6a953a0f09 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props +Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. + 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`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 463b9bff01..612a7ca727 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props +已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 + 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index d050533950..26fe962bf6 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -45,7 +45,7 @@ export interface SubmitOutcome { export interface CommandClaim { /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ readonly name: string - /** The text the draft carries for the claim (`/name ` in English, a localized token elsewhere). */ + /** Inserted command text with its argument separator; the bare complete name also retains the claim. */ readonly token: string readonly hint?: string readonly attachments?: boolean diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts index 09ef7b6727..7f2bec56ee 100644 --- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -34,8 +34,9 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () = if (node.getStyle() === TOKEN_STYLE) node.setStyle('') return } - const token = activeToken() const text = node.getTextContent() + const active = activeToken() + const token = text === active?.trimEnd() ? text : active if (token === null || !text.startsWith(token)) { if (node.getStyle() === TOKEN_STYLE) node.setStyle('') return diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 492aba62df..f11592ba5a 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -11,6 +11,8 @@ * keydown AFTER compositionend, so a root-element composition watch holds the * guard for 10ms more (the old textarea's proven window); keyCode * 229 is the legacy signal engines emit without isComposing. + * The root's composition attribute suppresses placeholders until both the + * native composition and the editor's final text reconciliation finish. */ import type { LexicalEditor } from 'lexical' import { @@ -57,12 +59,20 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer // root element and re-arms on root swaps. let composing = false let composingUntil = 0 + let rootElement: HTMLElement | null = null + const syncComposition = (): void => { + rootElement?.toggleAttribute('data-composer-composing', composing || editor.isComposing()) + } const onCompositionStart = (): void => { composing = true + syncComposition() } const onCompositionEnd = (): void => { composing = false composingUntil = Date.now() + 10 + // The native event can precede the committed draft, including an empty + // cancellation. The callback also runs when no document text changed. + editor.update(() => {}, { onUpdate: syncComposition }) } const recentlyComposing = (): boolean => composing || Date.now() < composingUntil @@ -79,9 +89,15 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer editor.registerRootListener((root, prevRoot) => { prevRoot?.removeEventListener('compositionstart', onCompositionStart) prevRoot?.removeEventListener('compositionend', onCompositionEnd) + prevRoot?.removeAttribute('data-composer-composing') + composing = false + composingUntil = 0 + rootElement = root root?.addEventListener('compositionstart', onCompositionStart) root?.addEventListener('compositionend', onCompositionEnd) + syncComposition() }), + editor.registerUpdateListener(syncComposition), editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), // Tab acts only when the trigger menu has a highlighted completion; diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index 3a72a2a189..74ca400217 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -96,7 +96,7 @@ export function registerTextRefDecoration( const getMatch = (text: string): { start: number; end: number } | null => { const claim = activeToken() for (const range of scanTextRefs(text, lexiconOf())) { - if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim) continue + if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim.trimEnd()) continue return { start: range.start, end: range.end } } return null diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 81aa519346..7d1e503777 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -27,6 +27,11 @@ function argsAfter(draft: string, token: string): string { return '' } +/** A claimed name may stand alone; arguments require the token's separator. */ +function retainsClaim(draft: string, token: string): boolean { + return draft.startsWith(token) || draft === token.trimEnd() +} + /** The submit-plane slice of the published InputState. */ export interface SubmitSnapshot { readonly phase: InputState['phase'] @@ -83,9 +88,9 @@ export class SubmitMachine { } } - /** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */ + /** The complete command name retains its claim with or without the argument separator. */ private onDraftChanged(draft: string): readonly InputEffect[] { - if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { + if (this.phase === 'claimed' && this.claim !== undefined && !retainsClaim(draft, this.claim.token)) { this.phase = 'plain' this.claim = undefined } @@ -200,7 +205,7 @@ export class SubmitMachine { } const text = ev.message ?? ev.outcome?.text if (ev.draft === flight.attempt.draftSnapshot - && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { + && this.claim !== undefined && retainsClaim(ev.draft, this.claim.token)) { this.phase = 'claimed' return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } 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 abd5911c90..6369e5728d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -195,6 +195,14 @@ color: var(--dsw-alias-label-caption); } +.input[data-composer-composing] p:last-child::after { + content: none; +} + +.input[data-composer-composing] + .placeholder { + visibility: hidden; +} + /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. Insets mirror .input's pads (the other right 4px sits on .scroll). One ellipsized line: a wrapped second line would clip against the docked diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c017da7073..6476e83878 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1266,6 +1266,37 @@ describe('decorations', () => { expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) }) + it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => { + const { shell, textarea, view } = bench() + expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull() + fireEvent.compositionStart(textarea) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + act(() => { shell.setDraft('z') }) + act(() => { shell.setDraft('') }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(textarea, { data: '' }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + await act(async () => {}) + expect(textarea.hasAttribute('data-composer-composing')).toBe(false) + + act(() => { + shell.setDraft('/目标 ') + shell.beginCommand( + { name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { start: 0, end: 4, draftRev: shell.snapshot.draftRev }, + ) + }) + fireEvent.compositionStart(textarea) + act(() => { shell.setDraft('/目标 z') }) + act(() => { shell.setDraft('/目标 ') }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(textarea, { data: '这' }) + act(() => { shell.setDraft('/目标 这') }) + await act(async () => {}) + expect(textarea.hasAttribute('data-composer-composing')).toBe(false) + expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('') + }) + it('an inserted reference renders a real chip capsule with its icon and label', () => { const { view, shell } = bench() const reference = { diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 94e34ed2c6..42bf54913c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -28,6 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' +import { $replaceDetectSpanWithText } from '../src/client/input/editor/span-map.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' @@ -306,6 +307,34 @@ describe('scenario: images ride an accepting command through the real pipeline', }) describe('scenario H: backspace breaks the token', () => { + it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed when its arguments and separator are deleted', async (name) => { + const { source } = commandSource([{ name, description: name, input: { hint: '目标内容' } }], + () => Promise.resolve({ kind: 'success' })) + const b = await scopedBench((triggers) => { triggers.registerSource(source) }) + b.type(`/${name}`) + fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 }) + expect(b.shell.snapshot.phase).toBe('claimed') + b.type(`/${name} 这是目标`) + for (let i = 0; i < 5; i++) { + act(() => { + b.shell.editor.update(() => { + const end = b.shell.snapshot.draft.length + $replaceDetectSpanWithText({ start: end - 1, end }, '') + }, { discrete: true }) + }) + } + expect(b.shell.snapshot.draft).toBe(`/${name}`) + expect(b.shell.snapshot.phase).toBe('claimed') + expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name}`) + b.type(`/${name} `) + await act(async () => {}) + expect(b.shell.snapshot.phase).toBe('claimed') + expect(b.shell.snapshot.draft).toBe(`/${name} `) + expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name} `) + b.type(`/${name}x`) + expect(b.shell.snapshot.phase).toBe('plain') + }) + it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() b.type('/goal') diff --git a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx index ea577859ef..daca265a89 100644 --- a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx +++ b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx @@ -4,13 +4,46 @@ * contenteditable reach the registered composer commands (the jsdom lane's * gesture entry, below the full component bench). */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { fireEvent } from '@testing-library/react' import { createEditor } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' describe('keymap keydown routing', () => { + it('clears composition presentation on root swaps and unregisters pending callbacks', async () => { + const editor = createEditor({ namespace: 'composition-root', onError: (e) => { throw e } }) + const first = document.createElement('div') + const second = document.createElement('div') + document.body.append(first, second) + onTestFinished(() => { + editor.setRootElement(null) + first.remove() + second.remove() + }) + editor.setRootElement(first) + const unregister = registerComposerKeymap(editor, { + arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, + canSubmit: () => false, submit: () => {}, intakeFiles: () => {}, pasteText: () => {}, + }) + onTestFinished(unregister) + fireEvent.compositionStart(first) + expect(first.hasAttribute('data-composer-composing')).toBe(true) + editor.setRootElement(second) + expect(first.hasAttribute('data-composer-composing')).toBe(false) + expect(second.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(first) + expect(first.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(second) + expect(second.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(second, { data: '' }) + unregister() + await Promise.resolve() + expect(second.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(second) + expect(second.hasAttribute('data-composer-composing')).toBe(false) + }) + it('routes Enter to the keymap submit handler', () => { const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } }) const root = document.createElement('div') diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx index 1fb06b9b25..92bfa3ce15 100644 --- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx +++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx @@ -429,9 +429,9 @@ describe('claim precedence over text-ref entities', () => { const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]]) - it('keeps a claimed lexicon-listed token plain and warn-styled until release', () => { + it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and warn-styled until release', (token) => { const editor = makeEditor() - let claim: string | null = '/plan' + let claim: string | null = token registerClaimDecoration(editor, () => claim) registerTextRefDecoration(editor, () => LEXICON, () => claim) editor.update(() => { @@ -449,7 +449,7 @@ describe('claim precedence over text-ref entities', () => { } }) // Claimed: the entity transform yields the seat, the claim transform styles it. - expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' }) + expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token }) // Released (the shell's refresh nudges the seat dirty): the entity captures it. claim = null editor.update(() => { @@ -458,11 +458,11 @@ describe('claim precedence over text-ref entities', () => { }, { discrete: true }) expect(leaf()).toEqual({ type: 'composer-text-ref', style: '', text: '/plan' }) // Re-claimed: the entity reverts to plain text and the warn style returns. - claim = '/plan' + claim = token editor.update(() => { const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild() if ($isTextNode(first)) first.markDirty() }, { discrete: true }) - expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' }) + expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token }) }) }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 563079ef9e..575c568c97 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -192,7 +192,7 @@ describe('submit-machine: claimed lifecycle', () => { expect(m.state.phase).toBe('submitting') }) - it('breaking startsWith(token) auto-releases back to plain', () => { + it('editing the command name releases back to plain', () => { const m = new SubmitMachine() m.dispatch({ type: 'claim', claim: claimOf('goal') }) m.dispatch({ type: 'draft-changed', draft: '/goal args fine' }) @@ -202,6 +202,33 @@ describe('submit-machine: claimed lifecycle', () => { expect(m.state.claim).toBeUndefined() }) + it.each([ + ['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '], + ['feedback', '/feedback '], ['feedback', '/反馈 '], + ])('retains %s as %s without its separator and submits an empty argument', (name, token) => { + const m = new SubmitMachine() + m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } }) + for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) { + m.dispatch({ type: 'draft-changed', draft }) + expect(m.state.phase).toBe('claimed') + expect(m.state.claim?.name).toBe(name) + } + const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() }) + const begin = effectAt(fx, 0, 'begin-submit') + expect(begin.args).toBe('') + m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() }) + expect(m.state.phase).toBe('claimed') + expect(m.state.claim?.name).toBe(name) + }) + + it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => { + const m = new SubmitMachine() + m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } }) + m.dispatch({ type: 'draft-changed', draft }) + expect(m.state.phase).toBe('plain') + expect(m.state.claim).toBeUndefined() + }) + it('explicit release returns to plain when nothing is in flight', () => { const m = new SubmitMachine() m.dispatch({ type: 'claim', claim: claimOf('goal') }) 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 4b9280fd4f..cb6ff3ec3a 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -6,14 +6,20 @@ left: 0; right: 0; z-index: 100; - /* Height cap: the 320px design maximum, clamped at runtime to the space - * above the composer (inline max-height set in MenuView.tsx). */ - max-height: 320px; + box-sizing: border-box; + /* Fits the built-in rows without scrolling and still clamps at runtime to + * the space above the composer (inline max-height set in MenuView.tsx). */ + max-height: 400px; overflow: hidden; /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens (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 + rounded ends. ui-theme's global scrollbar sheet renders these values. */ + --dsh-scrollbar-width: 10px; + --dsh-scrollbar-thumb-border: 3px; + --dsh-scrollbar-track-margin: 12px; padding: 4px; display: flex; flex-direction: column; @@ -24,6 +30,19 @@ box-shadow: var(--dsw-elevation-prominent); } +/* The hint covers content, not the scrollbar rail, and disappears once the + viewport reaches the final row. */ +.menu[data-overflow-below]::after { + content: ''; + position: absolute; + right: 14px; + bottom: 4px; + left: 4px; + height: 16px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-menu)); + pointer-events: none; +} + .viewport { display: flex; flex-direction: column; diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 2c0fe990a4..fc2bdc7cd1 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -12,7 +12,7 @@ * (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb * header pinned above the scrolling list. */ -import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' +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 type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -23,8 +23,8 @@ import type { MenuKey } from './locales.ts' /** Full menu props: injected face + the locale seat. */ export type MenuViewProps = MenuViewInjected & PropsLocale<'slash.menu'> -/** Design cap on the list height (figma SLASH 39:26572 MenuDropdown). */ -const MAX_HEIGHT = 320 +/** Height cap that fits the two headings and eight built-in command rows. */ +const MAX_HEIGHT = 400 /** DOM id of one option row (the aria-activedescendant target). */ function optionId(source: string, index: number): string { @@ -46,10 +46,20 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t () => headers.getSnapshot(), ) const listRef = useRef(null) + const viewportRef = useRef(null) + const [hasOverflowBelow, setHasOverflowBelow] = useState(false) // The list is bottom-anchored above the composer; clamp the design cap to // the space above it, re-measured on every store update (the anchor moves // when the composer grows). const maxHeight = useAnchoredMaxHeight(listRef, MAX_HEIGHT, state) + const updateOverflowHint = useCallback(() => { + const viewport = viewportRef.current + setHasOverflowBelow(viewport !== null + && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1) + }, []) + useLayoutEffect(() => { + updateOverflowHint() + }, [state, maxHeight, updateOverflowHint]) const highlight = state.open ? state.highlight : null // Focus stays in the textarea (combobox pattern), so the browser never // scrolls the active option into view on keyboard moves — do it here. @@ -76,7 +86,13 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t return ( // The listbox role sits on the scrolling viewport, not this shell: a // breadcrumb header is not an option, and a listbox may not carry one. -
+
{state.groups.map((group) => { const trail = crumbs.get(group.source) return trail === undefined ? null : ( @@ -103,10 +119,12 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t ) })}
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0) ? null diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 13fe143544..e2430f5670 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -238,7 +238,7 @@ describe('MenuView', () => { it('caps the list height at the design maximum when the composer sits low enough', () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(menuShell().style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('400px') }) it('clamps the list height to the space above the composer minus the safe margin', () => { @@ -251,12 +251,27 @@ describe('MenuView', () => { const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect') rect.mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(menuShell().style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('400px') rect.mockReturnValue({ bottom: 100 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) expect(menuShell().style.maxHeight).toBe('88px') }) + it('shows the bottom overflow hint until the list reaches its final row', () => { + mount(openState()) + const listbox = screen.getByRole('listbox') + Object.defineProperties(listbox, { + clientHeight: { configurable: true, value: 320 }, + scrollHeight: { configurable: true, value: 392 }, + scrollTop: { configurable: true, value: 0, writable: true }, + }) + fireEvent.scroll(listbox) + expect(menuShell().hasAttribute('data-overflow-below')).toBe(true) + listbox.scrollTop = 72 + fireEvent.scroll(listbox) + expect(menuShell().hasAttribute('data-overflow-below')).toBe(false) + }) + it('pointerdown outside the menu (no composer card ancestor) dismisses', () => { const { onDismiss } = mount(openState()) fireEvent.pointerDown(document.body) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index e023867f5b..c8a96f84b6 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 7c5ba6541601bbe506a0707e3ebfd75a1f30ade9 -README.zh.md: 6c82a1be39fff0ff827d515e870775aa9571ad2c +README.md: 291492b9b41df6570722fb2dbd14565c660ec350 +README.zh.md: 2883dd50ef6c70b2229b98c7434ca5317c5debe9 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 7c5ba65416..291492b9b4 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -59,7 +59,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Scrollbar rebinding -`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align beside a space-consuming bar. The two rendering paths are mutually exclusive by construction: Firefox takes the standard properties inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. +`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). WebKit-based browsers also read `--dsh-scrollbar-width`, `--dsh-scrollbar-thumb-border`, and `--dsh-scrollbar-track-margin`; a scroll surface may rebind them to keep a wide draggable rail around a narrower visible thumb or to inset the track from rounded ends. The two rendering paths are mutually exclusive by construction: Firefox takes the standard thin scrollbar inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so geometry and hover customization apply only through the pseudo-element path. ### Preference persistence diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 6c82a1be39..2883dd50ef 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -59,7 +59,7 @@ kind: "package-reference" ### 滚动条重新绑定 -`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准属性,WebKit 系引擎走伪元素,因此 hover token 只经由伪元素这条路径渲染。 +`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。WebKit 系浏览器还会读取 `--dsh-scrollbar-width`、`--dsh-scrollbar-thumb-border` 与 `--dsh-scrollbar-track-margin`;滚动表面可重新绑定它们,在较窄的可见滑块外保留较宽的拖动区域,或让轨道避开圆角两端。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准细滚动条,WebKit 系引擎走伪元素,因此几何与 hover 定制只经由伪元素路径生效。 ### 偏好持久化 diff --git a/packages/client/ui-theme/src/styles/scrollbar.css b/packages/client/ui-theme/src/styles/scrollbar.css index 71f67d4f57..d61bcbcedb 100644 --- a/packages/client/ui-theme/src/styles/scrollbar.css +++ b/packages/client/ui-theme/src/styles/scrollbar.css @@ -10,13 +10,14 @@ * * Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}: * the l1 pair here is the base-surface default, and an elevated surface - * (menu, popover, dialog) rebinds to the l2 pair on its own container. Both - * rendering paths below read the indirection, so one rebind reaches whichever - * path the engine took. */ + * (menu, popover, dialog) rebinds to the l2 pair on its own container. A + * scroll container may also rebind the three geometry variables below. */ body { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1); + --dsh-scrollbar-thumb-border: 0px; + --dsh-scrollbar-track-margin: 0px; /* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule below. A surface that must align itself beside a space-consuming bar (ConversationRoot's overlay composer seat) reads this instead of @@ -64,23 +65,27 @@ body { selector matching already does. Not inherited either, hence the unscoped selectors. */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: var(--dsh-scrollbar-width); + height: var(--dsh-scrollbar-width); } /* Track stays transparent so the thumb reads against whatever surface scrolls under it; only the thumb carries a token colour. */ ::-webkit-scrollbar-track { + margin-block: var(--dsh-scrollbar-track-margin); background: transparent; } ::-webkit-scrollbar-thumb { - border-radius: 4px; + border: var(--dsh-scrollbar-thumb-border) solid transparent; + border-radius: 999px; + corner-shape: round; background: var(--dsh-scrollbar-thumb); + background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { - background: var(--dsh-scrollbar-thumb-hover); + background-color: var(--dsh-scrollbar-thumb-hover); } /* Both scrollbars meeting in a corner: no separate token, so the corner diff --git a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts index c772bc92d5..9682bdeb3c 100644 --- a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts @@ -2,9 +2,9 @@ * Scrollbar stylesheet contract, asserted against the CSS text on disk: every * --dsw-alias-scrollbar-* token design-platform.css defines has a consumer, * scrollbar.css binds the base-surface pair through the rebindable - * indirection, the width variable mirrors the ::-webkit-scrollbar rule for - * consumers that align beside the bar, and elevated surfaces rebind that - * indirection in complete pairs. The expected token set is scanned out of + * indirection, the WebKit geometry reads the shared width, thumb-border, and + * track-margin variables, and elevated surfaces rebind the colour indirection + * in complete pairs. The expected token set is scanned out of * design-platform.css, so adding, renaming, or dropping a scrollbar token * moves these assertions with it. */ @@ -25,6 +25,11 @@ const DARK_ATTRIBUTE = '[data-ds-dark-theme]' const TOKEN_PREFIX = '--dsw-alias-scrollbar-' /** Prefix of the rebindable indirection scrollbar.css owns. */ const INDIRECTION_PREFIX = '--dsh-scrollbar-' +/** The elevation-aware colour variables surfaces rebind as one pair. */ +const COLOUR_INDIRECTIONS = new Set([ + `${INDIRECTION_PREFIX}thumb`, + `${INDIRECTION_PREFIX}thumb-hover`, +]) /** The one non-token rebind value: a surface that draws no thumb at all. */ const HIDDEN_THUMB = 'transparent' /** The elevation rebind, spelled per property: value-wholeness, not token shape. */ @@ -68,6 +73,14 @@ const platformRules = parseRules(platformCss) const scrollbarRules = parseRules(scrollbarCss) const sorted = (names: Iterable): string[] => [...names].sort() +/** Last value one selector declares for a property. */ +function declaration(rules: CssRule[], property: string, selectorPart: string): string | undefined { + return rules + .filter(rule => rule.selectors.includes(selectorPart)) + .flatMap(rule => rule.declarations) + .findLast(([name]) => name === property)?.[1] +} + /** * Scrollbar tokens defined by the rules whose selectors carry (or do not * carry) the dark palette attribute. @@ -175,7 +188,7 @@ for (const file of packageStylesheets()) { let rebindsElevation = false const ruleSurfaces: string[] = [] for (const [property, value] of rule.declarations) { - if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) { + if (COLOUR_INDIRECTIONS.has(property) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) { rebinds = true if (value !== HIDDEN_THUMB) rebindsElevation = true } @@ -248,48 +261,39 @@ describe('scrollbar.css base-surface binding', () => { it('routes the standard property and the WebKit thumb through the same indirection', () => { // A rebind on an elevated container has to move the Firefox and the WebKit // rendering together, which only holds while both read the same variable. - const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules - .filter(rule => rule.selectors.includes(selectorPart)) - .flatMap(rule => rule.declarations) - .findLast(([name]) => name === property)?.[1] - const thumbColor = declaration('scrollbar-color', 'body') + const thumbColor = declaration(scrollbarRules, 'scrollbar-color', 'body') expect(thumbColor).toBeDefined() const indirection = varReferences(thumbColor!)[0] expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`) - expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection]) + expect(varReferences(declaration(scrollbarRules, 'background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection]) }) }) -describe('scrollbar.css width variable', () => { +describe('scrollbar.css geometry variables', () => { const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width` + const THUMB_BORDER_VARIABLE = `${INDIRECTION_PREFIX}thumb-border` + const TRACK_MARGIN_VARIABLE = `${INDIRECTION_PREFIX}track-margin` + const GEOMETRY_VARIABLES = [WIDTH_VARIABLE, THUMB_BORDER_VARIABLE, TRACK_MARGIN_VARIABLE] - it('defines the width variable on body as a static length', () => { - // The overlay seat compensation reads a fixed number, not a second - // indirection: the mirror check below compares the WebKit rule against - // this value, so a var()-to-var() chain would compare one indirection to - // another instead of pinning the number. - const value = scrollbarRules + it('defines each geometry variable on body as a static length', () => { + const definitions = new Map(scrollbarRules .filter(rule => rule.selectors.includes('body')) .flatMap(rule => rule.declarations) - .findLast(([property]) => property === WIDTH_VARIABLE)?.[1] - expect(value, WIDTH_VARIABLE).toBeDefined() - expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/) + .filter(([property]) => GEOMETRY_VARIABLES.includes(property))) + for (const property of GEOMETRY_VARIABLES) { + expect(definitions.get(property), property).toMatch(/^\d+(?:\.\d+)?px$/) + } }) - it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => { - // The compensation stays aligned with the WebKit bar only while both read - // the same number. A change to one side without the other puts the overlay - // seat a band off from Chat on WebKit engines. - const variableValue = scrollbarRules - .filter(rule => rule.selectors.includes('body')) - .flatMap(rule => rule.declarations) - .findLast(([property]) => property === WIDTH_VARIABLE)?.[1] + it('routes WebKit scrollbar geometry through those variables', () => { const webkitWidth = scrollbarRules .filter(rule => rule.selectors.includes('::-webkit-scrollbar')) .flatMap(rule => rule.declarations) .findLast(([property]) => property === 'width')?.[1] expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined() - expect(webkitWidth).toBe(variableValue) + expect(varReferences(webkitWidth!)).toEqual([WIDTH_VARIABLE]) + expect(varReferences(declaration(scrollbarRules, 'border', '::-webkit-scrollbar-thumb')!)).toEqual([THUMB_BORDER_VARIABLE]) + expect(varReferences(declaration(scrollbarRules, 'margin-block', '::-webkit-scrollbar-track')!)).toEqual([TRACK_MARGIN_VARIABLE]) }) it('every reader of the width variable outside ui-theme references a defined variable', () => { @@ -422,10 +426,8 @@ describe('elevated surface rebinds', () => { // A surface rebinding only the resting colour keeps the l1 hover colour, // so the elevation is wrong only while the pointer is over the thumb. for (const { file, rule } of rebindRules) { - const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX)) - expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([ - `${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`, - ].sort()) + const properties = rule.declarations.map(([property]) => property).filter(property => COLOUR_INDIRECTIONS.has(property)) + expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual(sorted(COLOUR_INDIRECTIONS)) } }) @@ -459,7 +461,7 @@ describe('elevated surface rebinds', () => { // white)` and a crossed pair (the hover token bound to the resting // property); neither is what the contract says. for (const { file, rule } of rebindRules) { - const rebinds = rule.declarations.filter(([property]) => property.startsWith(INDIRECTION_PREFIX)) + const rebinds = rule.declarations.filter(([property]) => COLOUR_INDIRECTIONS.has(property)) const where = `${file} ${rule.selectors.join(', ')}` if (rebinds.every(([, value]) => value === HIDDEN_THUMB)) continue expect(rebinds.some(([, value]) => value === HIDDEN_THUMB), `${where}: mixes ${HIDDEN_THUMB} with an elevation`).toBe(false) From 5a878f7c3e1485acdcce4e20b6017bb278c7909f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 15:24:56 +0800 Subject: [PATCH 07/41] test(web): await restored theme before changing preference --- apps/web/tests/settings-chrome.e2e.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 55d64f199f..739d01ce18 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -262,6 +262,9 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const restoredDialog = page.getByRole('dialog', { name: '设置' }) const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' }) + // The boot palette precedes the settings mirror's saved preference. + const restoredDarkCube = restoredDialog.getByRole('button', { name: '深色' }) + await expect.poll(() => restoredDarkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await selectTheme(systemCube, 'system') await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), { From a43fa81f82ec383cc878a775c0cb95b0e19e7a34 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:08:19 +0800 Subject: [PATCH 08/41] test: respect startup and teardown budgets in Windows fixtures --- ...9-08-ci-readiness-and-completion.i18n.yaml | 4 +- .../2026-09-08-ci-readiness-and-completion.md | 4 + ...26-09-08-ci-readiness-and-completion.zh.md | 4 + .../inspector/tests/cordis-tree.host.spec.ts | 78 ++++++++++++++++--- .../tests/subagent-dsh-sdk.spec.ts | 12 ++- 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml index dbbc4ea266..f96c72c081 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.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/testing/2026-09-08-ci-readiness-and-completion.md -2026-09-08-ci-readiness-and-completion.md: 2249df5467189975aca2d73ec56c6cf82ec7b62f -2026-09-08-ci-readiness-and-completion.zh.md: ccf8e7c16903b66e39bc48e99460e5c5179ab51b +2026-09-08-ci-readiness-and-completion.md: c72048ef90b987e7d27992b1754736b2ce895093 +2026-09-08-ci-readiness-and-completion.zh.md: 1188d66249b3092416435da2e8496a657ad00677 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md index 2249df5467..c72048ef90 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md @@ -14,6 +14,8 @@ The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/acti A [worker-runtime coverage failure](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932) exhausts the slow-binding fixture's one-second compute allowance. Concurrent native Windows reproductions exceed that allowance before calling the binding. Worker initialization contributes measured active time; the delayed binding contributes idle time. +The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193) reports an SDK subprocess exit beyond a fixture's 200 ms confirmation window and an Inspector Worker startup beyond its ten-second default. Neither the protocol-error routing case nor the Cordis tree projection case measures those latency guarantees. + ## Decision The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first. @@ -28,6 +30,8 @@ The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) o The [worker-runtime binding test](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) allows five seconds of compute for source-worker initialization and delays the binding for 6.5 seconds. Charging that idle delay would still exceed the entire compute allowance. The case retains its 15-second test limit and 30-second wall ceiling, registers Context and reply-timer cleanup, and leaves the hot-loop, decoy-dispatch, wall-ceiling, and abort controls at their existing limits. Production budgets remain unchanged. +The [SDK subagent protocol-error test](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts) uses the provider's normal shutdown and exit grace periods and registers disposal before its assertions. The [Inspector tree tests](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts) pass the active test budget to Worker startup and register cleanup while startup is still pending. A cancelled test cannot receive a late-ready handle; cleanup awaits initialization and closes a successfully started Worker. Failed initialization already terminates the Worker before rejecting. A controlled late-start test verifies cancellation and closure through the real Worker's HTTP endpoint. Production defaults remain unchanged. + ## Alternatives considered **Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget. diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md index ccf8e7c169..1188d66249 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md @@ -14,6 +14,8 @@ Status: implemented 一次 [worker runtime coverage 失败](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932)耗尽了慢 binding 夹具的一秒计算额度。原生 Windows 并发复现在调用 binding 前已超过该额度。Worker 初始化会累计所测的活跃时间;延迟的 binding 累计空闲时间。 +[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193)报告了 SDK 子进程退出超过测试设置的 200 毫秒确认期限,以及 Inspector Worker 启动超过十秒默认期限。协议错误转发用例和 Cordis 树投影用例都不衡量这些延迟保证。 + ## 决策 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。 @@ -28,6 +30,8 @@ Status: implemented [Worker runtime binding 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)为源码 worker 初始化保留五秒计算额度,并将 binding 延迟设为 6.5 秒。若将该空闲延迟计费,仍会超过整个计算额度。用例保留 15 秒测试期限与 30 秒墙钟上限,登记 Context 和回复定时器的清理,并保持热循环、诱饵 dispatch、墙钟上限及取消控制用例的原有限制。生产预算不变。 +[SDK 子 Agent 协议错误测试](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts)使用提供方正常的关闭和退出等待时间,并在断言前登记清理。[Inspector 树测试](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts)将当前测试预算传给 Worker 启动,并在启动尚未完成时登记清理。取消后的测试不会收到随后才就绪的实例;清理等待初始化完成,并关闭成功启动的 Worker。初始化失败时,启动操作会在拒绝前终止 Worker。受控的延迟启动测试通过真实 Worker 的 HTTP 端点验证取消和关闭。生产默认值不变。 + ## 考虑过的替代方案 **增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。 diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 46bbad99be..9cabf89f48 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -2,10 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import WebSocket, { type RawData } from 'ws' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type TestContext } from 'vitest' import { CordisTreeCollector } from '../src/shared/cordis/collector.ts' import { observeCordisTree } from '../src/shared/cordis/observer.ts' -import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import * as inspectorBridge from '../src/host/bridge/controller.ts' +import type { InspectorHandle, InspectorOptions } from '../src/host/bridge/controller.ts' import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts' import { inspectorId } from '../src/shared/bridge/ids.ts' @@ -76,6 +77,19 @@ class CdpClient { } } +/** Own pending startup through cancellation; tree assertions do not measure Worker cold-start latency. */ +async function startTestInspector(options: InspectorOptions, test: TestContext): Promise { + const pending = inspectorBridge.startInspector({ ...options, startupTimeoutMs: test.task.timeout }) + test.onTestFinished(async () => { + // Failed starts terminate their Worker before rejecting. + const [started] = await Promise.allSettled([pending]) + if (started.status === 'fulfilled') await started.value.close() + }) + const handle = await pending + test.signal.throwIfAborted() + return handle +} + describe('Cordis tree inspection', () => { let inspector: InspectorHandle | undefined let cdp: CdpClient | undefined @@ -84,6 +98,48 @@ describe('Cordis tree inspection', () => { const observers: Array<() => void> = [] const fibers: Array<{ dispose(): Promise }> = [] + it('closes a late-starting Worker after test cancellation without returning its handle', async (test) => { + const release = Promise.withResolvers() + const controller = new AbortController() + const cleanups: Array[0]> = [] + let starting: Promise | undefined + const start = inspectorBridge.startInspector + const spy = vi.spyOn(inspectorBridge, 'startInspector').mockImplementation(async (options) => { + starting = start(options) + const handle = await starting + await release.promise + return handle + }) + test.onTestFinished(async () => { + release.resolve(undefined) + spy.mockRestore() + if (starting === undefined) return + const [started] = await Promise.allSettled([starting]) + if (started.status === 'fulfilled') await started.value.close() + }) + const pending = startTestInspector({ port: 0, captureFetch: false }, { + ...test, + signal: controller.signal, + onTestFinished: (cleanup) => { cleanups.push(cleanup) }, + }) + const result = pending.then( + handle => ({ status: 'fulfilled' as const, handle }), + (reason: unknown) => ({ status: 'rejected' as const, reason }), + ) + if (starting === undefined) throw new Error('Inspector startup was not called') + const handle = await starting + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ startupTimeoutMs: test.task.timeout })) + expect(cleanups).toHaveLength(1) + const cancelled = new Error('test cancelled during startup') + controller.abort(cancelled) + release.resolve(undefined) + const outcome = await result + expect(outcome.status).toBe('rejected') + expect('reason' in outcome ? outcome.reason : undefined).toBe(cancelled) + await cleanups[0]!(test) + await expect(fetch(handle.endpoint.httpUrl)).rejects.toThrow() + }) + afterEach(async () => { for (const dispose of observers.splice(0).reverse()) dispose() for (const fiber of fibers.splice(0).reverse()) await fiber.dispose() @@ -362,8 +418,8 @@ describe('Cordis tree inspection', () => { backend.close() }) - it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) const host = new Context() const hostFiber = host.plugin({ name: 'host-child', apply() {} }) fibers.push(hostFiber) @@ -540,8 +596,8 @@ describe('Cordis tree inspection', () => { expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') }) - it('emits only node-level DOM changes for Client snapshots', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('emits only node-level DOM changes for Client snapshots', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) const initialDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode const clientsNode = initialDocument.children?.find(node => node.localName === 'clients') @@ -598,8 +654,8 @@ describe('Cordis tree inspection', () => { }) }) - it('serves three document levels by default and withheld levels on demand', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('serves three document levels by default and withheld levels on demand', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) const host = new Context() let innerFiber: { uid: number | null } | undefined const outer = host.plugin({ @@ -680,14 +736,14 @@ describe('Cordis tree inspection', () => { expect(secondCdp.events.slice(offset).some(event => event.method === 'DOM.setChildNodes')).toBe(false) }) - it('restores a disconnected Client tree from a new transport generation', async () => { - inspector = await startInspector({ + it('restores a disconnected Client tree from a new transport generation', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20, - }) + }, test) clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' }) cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) await cdp.call('Runtime.enable') diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index b9fa8c55c0..ba26c0c69c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -6,7 +6,7 @@ * quiescent disposal are all exercised end to end. No model, no key. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -751,19 +751,17 @@ describe('dsh-subagent-dsh-sdk provider', () => { cwd: process.cwd(), provider: 'p', model: 'm', - // The fake dies as soon as the prompt arrives: FAKE_HANG_PROMPT plus a - // short-lived process is simulated by killing via dispose below instead; - // here use FAKE_MALFORMED to make the prompt reply violate the protocol. env: { FAKE_MALFORMED_PROMPT: '1' }, - shutdownTimeoutMs: 100, - disposeEofGraceMs: 200, - disposeGraceMs: 200, + shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error) => { seen.push(error.message) throw new Error('sink failure must be contained') }, } const run = await startSdkRun(request(), spec) + onTestFinished(() => run.dispose()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( From 9bae613128a725cc44dc140cf629a57f74524f94 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:16 +0800 Subject: [PATCH 09/41] ci: add an opt-in canary value to the CI pool failover switches The per-platform failover repository variables (DSH_CI_FAILOVER_LINUX and DSH_CI_FAILOVER_WINDOWS) accept a third value alongside the existing selfhosted routing. Set by a repository writer, the value retargets the always-on pull-request and master lanes onto the matching hosted-runner labels (16-core pools map to the 16-vCPU tier, standard legs to 4-vCPU); unset or any other value keeps today's runners, so the leg is dormant until a writer opts in - a canary path for capacity fallback, never the default. Participating jobs: the three enterprise Linux workers, the node-compat legs, the four Windows lanes, and the all-checks-passed verdict in ci.yml; expected-filenames; the sandbox bwrap leg; and the two manual benchmark matrices (4-32 vCPU tiers map to equivalents; 64/96-core rows keep the fleet labels). Everything pinned to its own runner by spec, credential, or host capability keeps it under every value, and the release-shaped chains stay entirely hosted. request-review.yml was retired on master while this work was in flight and the branch takes the deletion. Specs pin the new branches: ci-workflow.spec.ts asserts the labels appear in the extended selectors, and ci-compatible-selfhosted.spec.ts evaluates the routing mode. --- .github/workflows/ci-master.yml | 44 +++++++++++++++- .github/workflows/ci.yml | 41 ++++++++++----- .github/workflows/expected-filenames.yml | 4 +- .github/workflows/sandbox.yml | 11 +++- scripts/ci-compatible-selfhosted.spec.ts | 3 ++ scripts/ci-workflow.spec.ts | 64 ++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index df9f7f2bcc..daf8a6cb80 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -284,7 +284,15 @@ jobs: # The named pools are restricted at the organization level to this repository. larger-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark' - runs-on: ${{ matrix.runner }} + # Default measures the repository's own fleet tiers; under the matching + # platform's blacksmith failover value the tiers Blacksmith offers (up to + # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core + # rows keep the fleet labels because Blacksmith has no such tier. + runs-on: >- + ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith') + && matrix.blacksmith + || matrix.runner }} timeout-minutes: 15 strategy: fail-fast: false @@ -294,50 +302,62 @@ jobs: - platform: linux cores: '4' runner: dsh-ubuntu-24-04-4core + blacksmith: blacksmith-4vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '8' runner: dsh-ubuntu-24-04-8core + blacksmith: blacksmith-8vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '16' runner: dsh-ubuntu-24-04-16core + blacksmith: blacksmith-16vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '32' runner: dsh-ubuntu-24-04-32core + blacksmith: blacksmith-32vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '64' runner: dsh-ubuntu-24-04-64core + blacksmith: '' workload: typecheck - platform: linux cores: '96' runner: dsh-ubuntu-24-04-96core + blacksmith: '' workload: typecheck - platform: windows cores: '4' runner: dsh-windows-2025-4core + blacksmith: blacksmith-4vcpu-windows-2025 workload: production-site - platform: windows cores: '8' runner: dsh-windows-2025-8core + blacksmith: blacksmith-8vcpu-windows-2025 workload: production-site - platform: windows cores: '16' runner: dsh-windows-2025-16core + blacksmith: blacksmith-16vcpu-windows-2025 workload: production-site - platform: windows cores: '32' runner: dsh-windows-2025-32core + blacksmith: blacksmith-32vcpu-windows-2025 workload: production-site - platform: windows cores: '64' runner: dsh-windows-2025-64core + blacksmith: '' workload: production-site - platform: windows cores: '96' runner: dsh-windows-2025-96core + blacksmith: '' workload: production-site steps: - uses: actions/checkout@v6 @@ -375,7 +395,15 @@ jobs: # Windows runs both blocking build targets concurrently through run-gates. consolidated-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark' - runs-on: ${{ matrix.runner }} + # Default measures the repository's own fleet tiers; under the matching + # platform's blacksmith failover value the tiers Blacksmith offers (up to + # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core + # rows keep the fleet labels because Blacksmith has no such tier. + runs-on: >- + ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith') + && matrix.blacksmith + || matrix.runner }} timeout-minutes: 15 strategy: fail-fast: false @@ -385,50 +413,62 @@ jobs: - platform: linux cores: '4' runner: dsh-ubuntu-24-04-4core + blacksmith: blacksmith-4vcpu-ubuntu-2404 workers: '4' - platform: linux cores: '8' runner: dsh-ubuntu-24-04-8core + blacksmith: blacksmith-8vcpu-ubuntu-2404 workers: '8' - platform: linux cores: '16' runner: dsh-ubuntu-24-04-16core + blacksmith: blacksmith-16vcpu-ubuntu-2404 workers: '16' - platform: linux cores: '32' runner: dsh-ubuntu-24-04-32core + blacksmith: blacksmith-32vcpu-ubuntu-2404 workers: '32' - platform: linux cores: '64' runner: dsh-ubuntu-24-04-64core + blacksmith: '' workers: '32' - platform: linux cores: '96' runner: dsh-ubuntu-24-04-96core + blacksmith: '' workers: '32' - platform: windows cores: '4' runner: dsh-windows-2025-4core + blacksmith: blacksmith-4vcpu-windows-2025 workers: '2' - platform: windows cores: '8' runner: dsh-windows-2025-8core + blacksmith: blacksmith-8vcpu-windows-2025 workers: '2' - platform: windows cores: '16' runner: dsh-windows-2025-16core + blacksmith: blacksmith-16vcpu-windows-2025 workers: '2' - platform: windows cores: '32' runner: dsh-windows-2025-32core + blacksmith: blacksmith-32vcpu-windows-2025 workers: '2' - platform: windows cores: '64' runner: dsh-windows-2025-64core + blacksmith: '' workers: '2' - platform: windows cores: '96' runner: dsh-windows-2025-96core + blacksmith: '' workers: '2' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05b444a4..04639ea7fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: # repository state — not PR-editable, no merge required) retargets all # three onto the in-house # vm-backup pool and re-running the failed jobs is the entire switch — - # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. + # Setting the variable to 'blacksmith' instead routes the same jobs onto + # Blacksmith's hosted runners at the matching vCPU size (see + # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native @@ -39,7 +42,8 @@ jobs: node-24: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -105,7 +109,8 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -226,7 +231,8 @@ jobs: node-24-consumers: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -327,9 +333,12 @@ jobs: node-compat: if: github.event_name == 'pull_request' - # This job admits only repository-owned PR code to the persistent shared VM. + # Under the selfhosted leg this job admits only repository-owned PR code + # to the persistent shared VM; the blacksmith branch targets ephemeral + # runners and carries none of those predicates. runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.login != 'dependabot[bot]' @@ -451,7 +460,8 @@ jobs: windows-build: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -495,7 +505,8 @@ jobs: windows-coverage: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -571,7 +582,8 @@ jobs: windows-native-tests: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -620,7 +632,8 @@ jobs: if: github.event_name == 'pull_request' continue-on-error: true runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -677,9 +690,13 @@ jobs: # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the - # required Linux workers and runs on the vm-backup pool. + # required Linux workers and runs on the vm-backup pool. Under the + # 'blacksmith' value the verdict shares Blacksmith's pool with its workers + # through the same selector, so a Blacksmith outage strands both together — + # the accepted consequence of the explicit opt-in switch. runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml index 59320b9261..99c9886e35 100644 --- a/.github/workflows/expected-filenames.yml +++ b/.github/workflows/expected-filenames.yml @@ -18,7 +18,9 @@ env: jobs: expected-filenames: name: no golden filenames - runs-on: ubuntu-latest + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 97b7e298d7..713616f3b6 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -44,6 +44,12 @@ jobs: fail-fast: false matrix: include: + # Only the bwrap leg has a Blacksmith equivalent: the earlier + # migration dispatch runs measured that Blacksmith's Linux images do + # not enforce Landlock (the run-guard would turn the leg red) and + # its macOS image is unverified, so those legs stay on GitHub's + # images under every failover value — see + # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md. - os: ubuntu-latest runner: bwrap - os: ubuntu-24.04 @@ -53,7 +59,10 @@ jobs: - os: macos-latest runner: seatbelt name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }}) - runs-on: ${{ matrix.os }} + runs-on: >- + ${{ matrix.runner == 'bwrap' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + && 'blacksmith-4vcpu-ubuntu-2404' + || matrix.os }} timeout-minutes: 20 steps: - uses: actions/checkout@v6 diff --git a/scripts/ci-compatible-selfhosted.spec.ts b/scripts/ci-compatible-selfhosted.spec.ts index 3ef7a8b813..619ae192f9 100644 --- a/scripts/ci-compatible-selfhosted.spec.ts +++ b/scripts/ci-compatible-selfhosted.spec.ts @@ -57,6 +57,9 @@ describe('Node compatibility self-hosted routing', () => { it('uses the Linux pool only for opted-in repository-owned PRs', () => { expect(route()).toEqual(labels) for (const mode of ['', 'hosted', 'unexpected']) expect(route({ mode })).toBe('ubuntu-latest') + // The blacksmith value routes the compatibility legs onto Blacksmith's + // standard Linux runner regardless of PR ownership (ephemeral runners). + expect(route({ mode: 'blacksmith' })).toBe('blacksmith-4vcpu-ubuntu-2404') expect(route({ author: 'dependabot[bot]', actor: 'maintainer' })).toBe('ubuntu-latest') expect(route({ repository: 'outsider/fork', fork: true })).toBe('ubuntu-latest') expect(route({ repository: 'outsider/fork', fork: false })).toBe('ubuntu-latest') diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 46177da403..bfe95f28ef 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' import * as yaml from 'js-yaml' import { describe, expect, it } from 'vitest' @@ -135,6 +136,7 @@ describe('CI workflow', () => { expect(job['runs-on']).toContain('self-hosted') expect(job['runs-on']).toContain('dsh-win-ci') expect(job['runs-on']).toContain('dsh-windows-2025-16core') + expect(job['runs-on']).toContain('blacksmith-16vcpu-windows-2025') expect(job.if).toBe("github.event_name == 'pull_request'") } @@ -273,10 +275,43 @@ describe('CI workflow', () => { expect(job['runs-on'], `${jobName} runs-on must use the Linux failover switch`).toContain('DSH_CI_FAILOVER_LINUX') expect(job['runs-on'], `${jobName} runs-on must not use the Windows failover switch`).not.toContain('DSH_CI_FAILOVER_WINDOWS') expect(job['runs-on']).toContain('vm-backup') + expect(job['runs-on']).toContain('blacksmith-16vcpu-ubuntu-2404') } expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS') expect(aggregate['runs-on']).toContain('vm-backup') + expect(aggregate['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + + // Evaluating the full selector, not just substring containment, proves the + // blacksmith branch is standalone: it must not fall through to the + // self-hosted pool when the two values are mutually exclusive. + const selectors = { + linux: node24['runs-on'] as string, + linuxAggregate: aggregate['runs-on'] as string, + windows: windowsBuild['runs-on'] as string, + } + const evaluate = (expression: string, vars: Record, login = 'maintainer'): unknown => { + const body = expression.trim().slice(3, -2) + return runInNewContext(body, { + vars, + fromJSON: JSON.parse, + github: { event: { pull_request: { user: { login } } } }, + }, { timeout: 1000 }) + } + for (const [name, selector, variable, pool, hosted] of [ + ['linux gates', selectors.linux, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'dsh-ubuntu-24-04-16core'], + ['linux aggregate', selectors.linuxAggregate, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'ubuntu-latest'], + ['windows lanes', selectors.windows, 'DSH_CI_FAILOVER_WINDOWS', ['self-hosted', 'dsh-win-ci', 'windows'], 'dsh-windows-2025-16core'], + ] as const) { + expect(evaluate(selector, { [variable]: 'blacksmith' }), `${name} blacksmith value`).toMatch(/^blacksmith-/) + expect(evaluate(selector, { [variable]: 'selfhosted' }), `${name} selfhosted value`).toEqual(pool) + // The blacksmith branch must not capture the selfhosted pool, and the + // dependabot exclusion applies to the pool, not to the blacksmith tier. + expect(evaluate(selector, { [variable]: 'selfhosted' }, 'dependabot[bot]'), `${name} dependabot on selfhosted`).toBe(hosted) + for (const mode of ['', 'hosted', 'unexpected']) { + expect(evaluate(selector, { [variable]: mode }), `${name} default on ${mode}`).toBe(hosted) + } + } // The run-gates aggregate lanes stop at the first blocking gate failure so // a red aggregate does not keep burning runner time on the remaining @@ -299,6 +334,35 @@ describe('CI workflow', () => { expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' }) }) + it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => { + const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames') + const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e') + expect(expectedFilenames['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(expectedFilenames['runs-on']).toContain("== 'blacksmith'") + expect(expectedFilenames['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + expect(expectedFilenames['runs-on']).toContain("'ubuntu-latest'") + expect(sandbox['runs-on']).toContain("matrix.runner == 'bwrap'") + expect(sandbox['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(sandbox['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark'] as const) { + const benchmark = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), name) + if (!isRecord(benchmark.strategy) || !isRecord(benchmark.strategy.matrix) || !Array.isArray(benchmark.strategy.matrix.include)) { + throw new TypeError(`${name} must define a matrix include list`) + } + expect(benchmark['runs-on']).toContain('matrix.blacksmith') + expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS') + for (const row of benchmark.strategy.matrix.include as Array>) { + expect(typeof row.blacksmith, `${name} ${row.cores}-core row must declare a blacksmith label`).toBe('string') + if (row.cores === '64' || row.cores === '96') { + expect(row.blacksmith, `${name} ${row.cores}-core row has no Blacksmith tier`).toBe('') + } else { + expect(row.blacksmith).toContain(`blacksmith-${row.cores}vcpu`) + } + } + } + }) + it('runs required benchmarks on standard hosted Linux independently of failover', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const benchmark = workflowJob(workflow, 'node-24-bench') From d79294f23a2fd62d37f7c997ec97e51fec9d905a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:28 +0800 Subject: [PATCH 10/41] fix(scripts): enumerate repo files with a dirent walker instead of node fs.glob Node's internal fs.glob, from some 24.x releases, lstat-probes / while expanding a ** pattern over a symlinked file and throws ENOTDIR instead of skipping (observed on node 24.13.0 scanning snapshots/acp/image-compaction's symlinked system-prompt.expected.md, which failed verify-md-wrap inside check:ci:static). uniqueRepoFiles now walks with dirent types, never probing a path under a file, and mirrors fs.glob's dot:false and follow:false semantics: the four repo-files gate pattern sets enumerate identically. Broken or cyclic symlinks fail loudly in the caller's realpathSync, and pattern forms the walker does not model (metacharacters, a trailing **, empty segments) are rejected up front. repo-files.spec.ts pins the symlink, broken-link, and rejection behavior. --- scripts/repo-files.spec.ts | 176 +++++++++++++++++++++++++++++++++++++ scripts/repo-files.ts | 174 +++++++++++++++++++++++++++++++++++- 2 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 scripts/repo-files.spec.ts diff --git a/scripts/repo-files.spec.ts b/scripts/repo-files.spec.ts new file mode 100644 index 0000000000..60875341d7 --- /dev/null +++ b/scripts/repo-files.spec.ts @@ -0,0 +1,176 @@ +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { uniqueRepoFiles } from './repo-files.ts' + +interface Tree { + root: string + clean: () => void +} + +function makeTree(): Tree { + const parent = mkdtempSync(join(tmpdir(), 'repo-files-')) + const root = join(parent, 'root') + const outside = join(parent, 'outside') // reachable only through a symlinked dir + mkdirSync(join(root, 'a'), { recursive: true }) + writeFileSync(join(root, 'a', 'snap.md'), 'real\n') + mkdirSync(join(root, 'd'), { recursive: true }) + symlinkSync(join(root, 'a', 'snap.md'), join(root, 'd', 'snap.md')) + mkdirSync(outside, { recursive: true }) + writeFileSync(join(outside, 'snap.md'), 'behind a symlinked dir\n') + symlinkSync(outside, join(root, 'linked-dir')) + mkdirSync(join(root, '.hidden'), { recursive: true }) + writeFileSync(join(root, '.hidden', 'snap.md'), 'hidden by dot\n') + return { root, clean: () => { rmSync(parent, { recursive: true, force: true }) } } +} + +describe('uniqueRepoFiles', () => { + it('enumerates ** matches without probing a symlinked file as a directory', () => { + const tree = makeTree() + try { + // Node's internal glob (from some 24.x releases) lstat-probes + // /snap.md while expanding `**/snap.md` and throws ENOTDIR. + // The walker must return the real files, deduplicated by canonical + // target, without throwing on any node version. + const files = uniqueRepoFiles(tree.root, ['**/snap.md']) + const rootReal = realpathSync(tree.root) + const reals = files.map(file => relative(rootReal, file.real)).sort() + expect(reals).toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('does not follow symlinked directories under ** or wildcard-match dot names', () => { + const tree = makeTree() + try { + const files = uniqueRepoFiles(tree.root, ['**/*.md']) + const paths = files.map(file => relative(tree.root, file.abs)).sort() + // The symlinked d/snap.md dedupes onto its a/snap.md target; the + // linked-dir and .hidden targets must not appear at all. + expect(paths).toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('follows a literal segment that names a symlinked directory', () => { + const tree = makeTree() + try { + // Node glob resolves literal segments with stat, so `linked-dir/**` and + // `linked-dir/*` enter the symlinked directory's target; `**` and + // wildcard segments resolve with dirent types and do not. + const files = uniqueRepoFiles(tree.root, ['linked-dir/**/*.md']) + expect(files.map(file => relative(tree.root, file.abs))).toEqual([join('linked-dir', 'snap.md')]) + expect(uniqueRepoFiles(tree.root, ['linked-dir/*.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('linked-dir', 'snap.md')]) + // A wildcard first segment never enters the symlinked directory. + expect(uniqueRepoFiles(tree.root, ['*/snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('follows repeated literal symlinked directories like node glob and terminates', () => { + const parent = mkdtempSync(join(tmpdir(), 'repo-files-cycle-')) + try { + const root = join(parent, 'root') + mkdirSync(join(root, 'a'), { recursive: true }) + writeFileSync(join(root, 'a', 'snap.md'), 'x\n') + symlinkSync(root, join(root, 'cyc')) + // Each literal `cyc` segment resolves through stat and enters the + // symlinked directory again, exactly as node glob does for a repeated + // literal; recursion stays bounded because each literal consumes one + // pattern segment and `**` only enters real directories. + expect(uniqueRepoFiles(root, ['cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'a', 'snap.md')]) + expect(uniqueRepoFiles(root, ['cyc/cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'cyc', 'a', 'snap.md')]) + expect(uniqueRepoFiles(root, ['cyc/cyc/cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'cyc', 'cyc', 'a', 'snap.md')]) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('reports a matched file canonical target for downstream existence checks', () => { + const tree = makeTree() + try { + const files = uniqueRepoFiles(tree.root, ['a/*.md']) + expect(files).toHaveLength(1) + expect(files[0]!.real).toBe(join(realpathSync(tree.root), 'a', 'snap.md')) + } finally { + tree.clean() + } + }) + + it('fails loudly on a broken symlink instead of shrinking the corpus', () => { + const tree = makeTree() + try { + // A healthy tree with symlinked files must not throw. + expect(() => uniqueRepoFiles(tree.root, ['a/*.md'])).not.toThrow() + } finally { + tree.clean() + } + const parent = mkdtempSync(join(tmpdir(), 'repo-files-broken-')) + try { + const root = join(parent, 'root') + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'gone.md'), 'x\n') + symlinkSync(join(root, 'gone.md'), join(root, 'broken-link.md')) + rmSync(join(root, 'gone.md')) + // realpathSync on the matched broken link must throw, exactly as it did + // under the node-glob implementation, instead of silently shrinking the + // scanned corpus. + expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow() + // A broken symlink under a literal non-final segment matches nothing, + // again as node glob silently returns no match for it. + symlinkSync(join(root, 'gone-dir'), join(root, 'broken-dir')) + expect(uniqueRepoFiles(root, ['broken-dir/*.md'])).toEqual([]) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('rejects glob syntax the walker does not model instead of matching nothing', () => { + const tree = makeTree() + try { + // Node glob would interpret the bracket class; the walker must fail + // loudly rather than expand the pattern as a literal and quietly match + // nothing. + expect(() => uniqueRepoFiles(tree.root, ['**/*.[cm]d'])).toThrow(/does not model glob syntax/) + } finally { + tree.clean() + } + }) + + it('rejects a trailing ** segment instead of silently returning nothing', () => { + const tree = makeTree() + try { + expect(() => uniqueRepoFiles(tree.root, ['a/**'])).toThrow(/trailing \*\*/) + // A trailing slash yields an empty final segment that can never match. + expect(() => uniqueRepoFiles(tree.root, ['a/**/'])).toThrow(/empty segments/) + } finally { + tree.clean() + } + }) + + it('folds a . segment and rejects a .. segment like node glob semantics', () => { + const tree = makeTree() + try { + // Node glob normalizes a `.` segment away, so `./a/snap.md` matches + // a/snap.md instead of looking for a directory named `.`. + expect(uniqueRepoFiles(tree.root, ['./a/snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + expect(uniqueRepoFiles(tree.root, ['a/./snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + // A `..` segment escapes the scanned root; the walker must fail loudly + // rather than silently match nothing. + expect(() => uniqueRepoFiles(tree.root, ['a/../snap.md'])).toThrow(/does not model \.\. segments/) + } finally { + tree.clean() + } + }) +}) diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts index 7c7aca276d..56d9cbf9f7 100644 --- a/scripts/repo-files.ts +++ b/scripts/repo-files.ts @@ -1,7 +1,7 @@ /** Shared repository file discovery and line-oriented reference scanning. */ -import { globSync, readFileSync, realpathSync } from 'node:fs' -import { relative, resolve, sep } from 'node:path' +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' /** One authored path plus its canonical target for symlink deduplication. */ export interface RepoFile { @@ -26,6 +26,173 @@ export function isArchivedAgentNotePath(path: string): boolean { return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/') } +/** + * Whether a pattern segment matches a directory or file name. Supports `*` and + * `?` inside a segment and mirrors node's glob `dot: false`: a segment whose + * first character is a wildcard does not match dot names. `**` is handled as a + * whole segment by the walker, never here. + */ +function segmentMatches(pattern: string, name: string): boolean { + if (name.startsWith('.') && (pattern.startsWith('*') || pattern.startsWith('?'))) return false + let expression = '' + for (const character of pattern) { + if (character === '*') expression += '.*' + else if (character === '?') expression += '.' + else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + return new RegExp(`^${expression}$`).test(name) +} + +/** + * Reject glob syntax the walker does not model. Node's `fs.glob` understands + * character classes, brace alternation, and extglobs; expanding those + * silently as literals would match nothing and quietly shrink a gate's + * corpus, so a pattern segment using them fails loudly instead. Pure literal + * segments must therefore contain none of the rejected metacharacters + * either, even where node glob would read them literally. + */ +const UNSUPPORTED_GLOB = /[[\]{}()!+@\\]/ + +function assertSupportedSegment(segment: string): void { + if (UNSUPPORTED_GLOB.test(segment)) { + throw new TypeError(`repo-files walker does not model glob syntax in segment: ${segment}`) + } +} + +/** + * Walk `root` matching one repository-relative glob without node's `fs.glob`. + * The repository's own walker exists because node's internal glob, from some + * 24.x releases, lstat-probes `/` for symlinked files + * while expanding `**` and throws ENOTDIR instead of skipping (observed on + * node 24.13.0 scanning `snapshots/acp/image-compaction`'s symlinked + * `system-prompt.expected.md`). The walker decides directoryhood from dirent + * types and stat results, never by probing a path under a file, so the same + * tree enumerates identically on every node version. + * + * Segment semantics: literal segments resolve through `stat`, so a literal + * naming a symlinked directory enters that directory exactly as node glob + * does — including a literal repeated across segments, which node glob + * resolves each time; wildcard segments and `**` resolve through dirent + * types, so they never enter symlinked directories and never match dot + * names. Node documents `follow: false` only for `**` expansion, so the + * wildcard-side behavior is this walker's own contract, pinned by + * repo-files.spec.ts, rather than a cross-version node guarantee; the four + * consuming gates' current patterns contain no wildcard segment over a + * symlinked directory, so the corpus is unchanged. `**` spans zero or more + * directories. The final segment matches files and symlinks; a broken or + * cyclic symlink then fails loudly in the caller's realpathSync rather than + * shrinking the scanned corpus, while a broken symlink under a literal + * non-final segment matches nothing, again as node glob does. Traversal + * terminates without a visited set: each literal segment consumes one pattern + * segment per recursion, and `**` recurses only into real directories, which + * form a finite tree because symlinked directories are never expanded by it. + * Pattern segments support `*`, `?`, and literals that contain none of + * `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and + * empty segments, is rejected loudly up front, while a `.` segment is folded + * away exactly as node glob normalizes it. Returns repository-relative slash + * paths, sorted. + */ +function expandGlob(root: string, pattern: string): string[] { + const segments: string[] = [] + for (const segment of pattern.split('/')) { + if (segment === '.') { + // Node glob normalizes a `.` segment away; dropping it here keeps + // `./README.md` and `a/./b` matching exactly as node glob does instead + // of silently matching nothing. + continue + } + if (segment === '..') { + // A `..` segment escapes the scanned root and interacts with `**` in + // ways node glob special-cases; no gate pattern uses one, so the walker + // rejects the form loudly instead of silently matching nothing. + throw new TypeError(`repo-files walker does not model .. segments in pattern: ${pattern}`) + } + if (segment === '') { + // A leading, doubled, or trailing slash yields an empty segment that + // can never match an entry; node glob would tolerate the form, so the + // walker must reject it loudly instead of silently returning nothing. + throw new TypeError(`repo-files walker does not model empty segments in pattern: ${pattern}`) + } + segments.push(segment) + } + if (segments.length === 0) { + throw new TypeError(`repo-files walker does not model a pattern with no segments: ${pattern}`) + } + for (const segment of segments) { + if (segment !== '**') assertSupportedSegment(segment) + } + // A trailing `**` would match files, directories, and symlinks below the + // prefix; the walker models `**` only as a directory-spanning segment, so + // it must reject the form loudly instead of silently returning nothing. + if (segments[segments.length - 1] === '**') { + throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`) + } + const out: string[] = [] + + const visit = (dirAbs: string, dirRel: string, index: number): void => { + if (index >= segments.length) return + if (segments[index] !== '**') { + visitSegment(dirAbs, dirRel, index) + return + } + // `**` consumes zero directories here and one directory per recursion. + visitSegment(dirAbs, dirRel, index + 1) + for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + if (entry.name.startsWith('.') || !entry.isDirectory()) continue + visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index) + } + } + + const visitSegment = (dirAbs: string, dirRel: string, index: number): void => { + if (index >= segments.length) return + const segment = segments[index] + if (segment === undefined) return + if (segment === '**') { + visit(dirAbs, dirRel, index) + return + } + const last = index === segments.length - 1 + for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + if (!segmentMatches(segment, entry.name)) continue + const childAbs = join(dirAbs, entry.name) + const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}` + if (last) { + // Files and symlinks match; a broken or cyclic symlink then fails + // loudly in the caller's realpathSync exactly as node glob did, + // rather than silently shrinking the scanned corpus. + if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel) + continue + } + if (entry.isDirectory()) { + visitSegment(childAbs, childRel, index + 1) + continue + } + // A literal non-final segment resolves through stat like node glob's, + // so it enters a symlinked directory; wildcard segments never reach + // this branch because they are matched from dirent types above. + if (!entry.isSymbolicLink() || hasWildcard(segment)) continue + let target: ReturnType + try { + target = statSync(childAbs) + } catch { + // A broken symlink matches nothing under a literal segment, as node + // glob silently returns no match for it. + continue + } + if (!target.isDirectory()) continue + visitSegment(childAbs, childRel, index + 1) + } + } + + visit(root, '.', 0) + return out.sort() +} + +/** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */ +function hasWildcard(segment: string): boolean { + return segment.includes('*') || segment.includes('?') +} + /** * Expand repository-relative globs and deduplicate symlinked files. * @param root - absolute repository root. @@ -41,8 +208,7 @@ export function uniqueRepoFiles( const seen = new Set() const files: RepoFile[] = [] for (const pattern of patterns) { - for (const match of globSync(pattern, { cwd: root })) { - const repoPath = match.split(sep).join('/') + for (const repoPath of expandGlob(root, pattern)) { if (isExcluded(repoPath)) continue const abs = resolve(root, repoPath) const real = realpathSync(abs) From acc8535390e9e74f4be33b66298b7dfd28c618e0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:36 +0800 Subject: [PATCH 11/41] docs(ci): record the canary failover value and the glob fix across internal notes The failover runbook, the node-compatibility note, the native-Windows note, and the serial-reference note record the third variable value's scope and exclusions (bilingual, pairing re-recorded); a new implemented process note owns the value's participating set, the dormant-by-default canary posture, and the walker-fix rationale. .github/AGENTS.md points at both notes. --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +-- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 10 +++---- .../2026-07-26-ci-failover-runbook.zh.md | 10 +++---- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- ...06-node-compatibility-selfhosted.i18n.yaml | 4 +-- ...026-09-06-node-compatibility-selfhosted.md | 2 +- ...-09-06-node-compatibility-selfhosted.zh.md | 2 +- ...26-09-09-blacksmith-failover-leg.i18n.yaml | 6 +++++ .../2026-09-09-blacksmith-failover-leg.md | 27 +++++++++++++++++++ .../2026-09-09-blacksmith-failover-leg.zh.md | 27 +++++++++++++++++++ .github/AGENTS.md | 2 +- 16 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 889010ca8d..2ce7922815 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: edb81b643d0cef2e5bc807005a9016324b8430ab -2026-07-21-serial-cross-platform-ci-reference.zh.md: 41fd9c032038f2a312978acf995febfdab34aeaa +2026-07-21-serial-cross-platform-ci-reference.md: 24022fea271d677a4588bd5dc9c7cb5b417ca8b7 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c9fbc84d8ff91803acc6bcd008607fc03136b832 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index edb81b643d..24022fea27 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation, the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under the `selfhosted` failover value, and Blacksmith's Windows runners under the `blacksmith` value (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 41fd9c0320..c9fbc84d8f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 故障切换取值下使用自托管 `[self-hosted, dsh-win-ci, windows]` 池,在 `blacksmith` 取值下使用 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md);另见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 90fdf49b49..ed606bb0ef 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: f24cb8b8239141cd1ccf468a566dba620dd3cfdd -2026-07-26-ci-failover-runbook.zh.md: 57c4a92a3af720d9b11b7a1ce7a1515b83c77339 +2026-07-26-ci-failover-runbook.md: a114d9bd25b2d07c51205f91b175eaa6f498c0e8 +2026-07-26-ci-failover-runbook.zh.md: 677e757d55307e2f07de7fb139ec95206c908066 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index f24cb8b823..a114d9bd25 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym ## Decision -The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. +The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. `ci-master.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. @@ -36,9 +36,9 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. +3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. -**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. +**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). **Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members. @@ -49,11 +49,11 @@ Capacity includes the master standby, main-CI jobs, and three release-rehearsal ### Switch back -Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to anything other than `selfhosted`). New runs resolve back to their hosted pools. Remove any extra instances that were registered during the incident. +Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to any value other than `selfhosted` or `blacksmith`). New runs resolve back to their hosted pools. Setting it to `blacksmith` keeps the jobs on Blacksmith until the value changes. Remove any extra instances that were registered during the incident. ### Trust boundary -The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. +The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the `selfhosted` legs; the `blacksmith` legs carry no exclusion), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 57c4a92a3a..677e757d55 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 +三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 `ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 @@ -36,9 +36,9 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 -**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 +**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 @@ -49,11 +49,11 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以 ### 切回 -删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回各自的托管池。若故障期间追加注册过实例,将其移除。 +删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 与 `blacksmith` 之外的任何值),新的运行即解析回各自的托管池。设为 `blacksmith` 会让作业留在 Blacksmith,直到该值改变。若故障期间追加注册过实例,将其移除。 ### 信任边界 -这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 +这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、Dependabot 由 `selfhosted` 腿排除;`blacksmith` 腿不带排除),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 56858d05b2..a542599011 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0 -2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe +2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5 +2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 690f8e6f9b..e4fc7cab8c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. +Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 9efa3cbcf3..27ad602c37 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,7 +14,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml index 1cde51d814..397723916f 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.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/process/2026-09-06-node-compatibility-selfhosted.md -2026-09-06-node-compatibility-selfhosted.md: c78092834123b837d100814be9beba52c1a41397 -2026-09-06-node-compatibility-selfhosted.zh.md: 6dcff8aa197c0995e4e90d2d56179340a41bc783 +2026-09-06-node-compatibility-selfhosted.md: c361d21d3e1093dd5c87bf2ba085bdd1acacb5d8 +2026-09-06-node-compatibility-selfhosted.zh.md: 80a9d8519d6084f5e01944b277882a51b2291e94 diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md index c780928341..c361d21d3e 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md @@ -10,7 +10,7 @@ The Node 22.19, 24.9, and 26 compatibility jobs consume hosted Linux minutes eve ## Decision -[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. +[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. The `blacksmith` failover value's branch drops those predicates: it targets ephemeral Blacksmith runners, so repository identity and fork status do not gate it (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). The temporary tool cache trades repeated Node downloads for isolation across concurrent runners and Node versions. A setup-node-only [ESM preload](../../../../scripts/ci-compatible-toolcache.mjs) assigns the cache inside the action process: the Actions runner overwrites reserved environment variables after reading step configuration. An executed path check rejects installations outside runner temp; compatibility processes do not inherit the preload. pnpm keeps its existing private setup destination and persistent content-addressed store. Compile caches and node-gyp headers use runner temp before the first pnpm invocation. No global Node symlink or system package changes are introduced. Hosted jobs retain their tool and package caching; self-hosted jobs do not restore or upload hosted package caches. The runner owns temporary-directory cleanup between jobs, and the shared image supplies native npm packages’ compiler and Python prerequisites. diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md index 6dcff8aa19..80a9d8519d 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。 +[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。`blacksmith` 故障切换取值下的分支放弃这些条件:它面向临时的 Blacksmith 运行器,因此仓库身份与 fork 状态不参与门控(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 临时工具缓存以重复下载 Node 为代价,换取并发运行器与 Node 版本之间的隔离。仅用于 setup-node 的 [ESM 预加载模块](../../../../scripts/ci-compatible-toolcache.mjs) 在 action 进程内指定缓存:Actions 运行器在读取步骤配置后会覆盖保留的环境变量。实际执行的路径检查拒绝运行器临时目录之外的安装;兼容性进程不继承预加载设置。pnpm 保留现有的私有安装目录和持久化内容寻址 store。编译缓存与 node-gyp 头文件在首次调用 pnpm 前就使用运行器临时目录。不引入全局 Node 符号链接或系统软件包变更。托管作业保留其工具与软件包缓存;自托管作业不恢复或上传托管软件包缓存。运行器负责作业之间的临时目录清理,共享镜像提供原生 npm 软件包所需的编译器和 Python 前置依赖。 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml new file mode 100644 index 0000000000..cc535290ee --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.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-09-blacksmith-failover-leg.md +2026-09-09-blacksmith-failover-leg.md: eaaa6f4d5c8326ae7686dc9f485143781d065313 +2026-09-09-blacksmith-failover-leg.zh.md: 562aed96327a86c24e38b502f2ef4756a11b91e1 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md new file mode 100644 index 0000000000..eaaa6f4d5c --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -0,0 +1,27 @@ +# Agent Note: Blacksmith as a failover leg for CI pools + +Status: implemented + +English | [中文](2026-09-09-blacksmith-failover-leg.zh.md) + +## Problem + +The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterprise-pool traffic onto the in-house `vm-backup` and `dsh-win-ci` pools, which are proven but bounded standbys. Blacksmith sells GitHub-hosted-style runners on demand, and its migration wizard proposed replacing the repository's `runs-on` labels wholesale, which would put credential-holding, benchmark, and spec-pinned jobs on third-party infrastructure as the default. The wholesale replacement is not acceptable as a default: it breaks the workflow contract specs, distorts benchmark and performance-budget measurements, and moves trust-boundary jobs without a decision record. + +## Decision + +`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. + +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. + +Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. + +## Alternatives considered + +Adopting the migration wizard wholesale. Rejected because it breaks the workflow contract specs, changes benchmark semantics and performance-budget calibration, and moves credential-holding and trust-boundary jobs to third-party infrastructure as the default; the earlier wizard migration (closed PR #3053) was rejected for cost and the same review findings. + +Keeping the in-house pools as the only failover target. Rejected as the sole option because they are a bounded standby; Blacksmith adds elastic capacity for the outage and experiment cases where the fleet itself is the degraded resource. + +## Consequences + +The blacksmith legs stay dormant until the value is set, so they are exercised only during an actual failover or an explicit experiment. The mirror trial ([PR #3841](https://github.com/deepseek-harness/deepseek-harness/pull/3841), closed) ran the participating set hardcoded onto the Blacksmith labels and confirmed that `blacksmith-4vcpu-ubuntu-2404`, `blacksmith-16vcpu-ubuntu-2404`, and `blacksmith-16vcpu-windows-2025` schedule and execute. Blacksmith registers its runners as self-hosted, so under the `blacksmith` value the `node-compat` legs run their toolcache-isolation and isolated-installation verification steps (gated on `runner.environment == 'self-hosted'`) rather than skipping them; all three legs passed on the trial image ([run 34322356689](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34322356689)). The same trial surfaced one environment finding that this change fixes: while expanding `**`, node 24.13's internal `fs.glob` lstat-probes `/` for a symlinked file and throws ENOTDIR instead of skipping ([run 34319926270](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34319926270)), failing `verify-md-wrap` inside `check:ci:static` on Blacksmith until [repo-files.ts](../../../../scripts/repo-files.ts) replaced `globSync` with a dirent walker; the fixed static lane is green on the same image. The trigger is the lstat-probe of a path beneath a matched symlink, not `**` over symlinked trees in general: the other `globSync` `**` call sites that scan symlink-bearing trees stayed green on the same run and needed no change. The benchmark matrices' 8 and 32 vCPU labels (ubuntu-2404 and windows-2025) were not part of the trial and are inferred by naming symmetry from the verified 4 and 16 vCPU labels; confirm with a manual dispatch before first use. The Landlock, Seatbelt, and linux-arm64 exclusions still rest on the prose dispatch measurements of the earlier migration attempt (closed PR #3053), recorded here as a known gap; the trial could not exercise those legs by design. Workflow specs pin the new branches — `ci-workflow.spec.ts` asserts the Blacksmith labels appear in the extended selectors, and `ci-compatible-selfhosted.spec.ts` evaluates the `blacksmith` routing mode — so a future rewrite of the selectors fails the unit gates. Default cost posture is unchanged: nothing runs on Blacksmith unless the variable says so. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md new file mode 100644 index 0000000000..562aed9632 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Blacksmith 作为 CI 池的故障切换支路 + +Status: implemented + +[English](2026-09-09-blacksmith-failover-leg.md) | 中文 + +## 问题 + +[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)把降级的企业池流量路由到自有的 `vm-backup` 与 `dsh-win-ci` 池——它们是经过验证但容量有界的备用池。Blacksmith 按需出售类似 GitHub 托管的运行器,其迁移向导提议整体替换仓库的 `runs-on` 标签,这会把持有凭据、基准与 spec 钉死的作业默认放到第三方基础设施上。整体替换不适合作为默认:它会打破 workflow 契约 spec、扭曲基准与性能预算测量,并在没有决策记录的情况下搬动信任边界作业。 + +## 决策 + +`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 + +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 + +其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 + +## 备选方案 + +整体采纳迁移向导。否决,因为它打破 workflow 契约 spec、改变基准语义与性能预算校准,并默认把持有凭据与信任边界的作业搬到第三方基础设施;更早的向导迁移(已关闭的 PR #3053)因成本与同样的 review 结论被否决。 + +只把自有池作为唯一故障切换目标。作为唯一选项被否决,因为它是容量有界的备用;Blacksmith 为「故障对象正是自有池本身」的故障与实验场景提供弹性容量。 + +## 后果 + +blacksmith 各腿在有人设置该值前保持休眠,因此只在真实故障切换或明确实验期间被使用。镜像试运行([PR #3841](https://github.com/deepseek-harness/deepseek-harness/pull/3841),已关闭)把参与集硬编码到 Blacksmith 标签上跑过,确认 `blacksmith-4vcpu-ubuntu-2404`、`blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025` 可调度可执行。Blacksmith 将其运行器注册为 self-hosted,因此在 `blacksmith` 取值下 `node-compat` 各腿会真实执行 toolcache 隔离与隔离安装校验步骤(以 `runner.environment == 'self-hosted'` 为条件),而非全部跳过;三条腿都在试运行镜像上通过([run 34322356689](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34322356689))。同一试运行还暴露了一个由本改动修复的环境发现:在 `**` 展开中遇到 symlink 文件时,node 24.13 的内部 `fs.glob` 会 lstat-probe `/<下一段>` 并抛 ENOTDIR 而非跳过([run 34319926270](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34319926270)),使 `check:ci:static` 内的 `verify-md-wrap` 在 Blacksmith 上失败,直到 [repo-files.ts](../../../../scripts/repo-files.ts) 用 dirent walker 替换 `globSync`;修复后的 static 通道在同一镜像上转绿。触发条件是 probe 已匹配 symlink 之下的路径,而非笼统的「`**` 扫过含 symlink 的树」:同一 run 中其它扫描含 symlink 树的 `globSync` `**` 调用点保持绿色,无需改动。两个基准矩阵的 8 与 32 vCPU 标签(ubuntu-2404 与 windows-2025)不在试运行范围内,是按已实测的 4/16 vCPU 标签命名对称推定的;首次使用前请用一次手动 dispatch 确认。Landlock、Seatbelt 与 linux-arm64 的排除仍基于更早迁移尝试(已关闭的 PR #3053)的散文式 dispatch 实测,此处记录为已知缺口;试运行按设计无法触发这几条腿。workflow spec 钉住了新分支——`ci-workflow.spec.ts` 断言扩展后的 selector 中出现 Blacksmith 标签,`ci-compatible-selfhosted.spec.ts` 求值 `blacksmith` 路由模式——因此未来重写 selector 会使单元门禁变红。默认成本姿态不变:除非变量指明,否则没有任何东西跑在 Blacksmith 上。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 100385608f..f4f3c5d555 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches and Dependabot hosted fallback ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md)). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). From 220678e57d135e21817dc31bc621e6b8111005e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 21:03:44 +0800 Subject: [PATCH 12/41] feat(manifest): declare plugin format, tags, and host compatibility --- .../2026-09-05-package-manifest-types.i18n.yaml | 4 ++-- .../2026-09-05-package-manifest-types.md | 4 ++++ .../2026-09-05-package-manifest-types.zh.md | 4 ++++ packages/util/package-manifest/README.i18n.yaml | 4 ++-- packages/util/package-manifest/README.md | 16 +++++++++++++++- packages/util/package-manifest/README.zh.md | 16 +++++++++++++++- packages/util/package-manifest/src/index.ts | 1 + packages/util/package-manifest/src/types.ts | 12 ++++++++++++ 8 files changed, 55 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml index c4907f9030..a15fd90d6d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.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-package-manifest-types.md -2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892 -2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697 +2026-09-05-package-manifest-types.md: 5e9834583a75a3fd4f1ec69379628c49d264e05b +2026-09-05-package-manifest-types.zh.md: 8a2399a19a097113d45f4915631d36ca30191dd5 diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md index 94317a9317..5e9834583a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md @@ -12,6 +12,8 @@ External packages need Harness manifest types without depending on boot or clien [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished. +Author metadata stays under `package.json.dsh`: optional `manifestVersion` identifies format `1`, `categories` carries free-form discovery tags, and `engines.dsh` declares compatible host versions as a SemVer range. Npm package identity and Node requirements retain their top-level fields. Categories are extensible tags rather than a closed role enum, so labels such as `skills` and `tools` do not constrain package composition. + Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local. App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. @@ -29,3 +31,5 @@ App-boot declares a production dependency because its published declarations ref Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior. Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added. + +Manifest format and host compatibility declarations have no enforcement in current installers or loaders. The type-only package supplies neither a SemVer parser nor an installation policy; its README records that limitation so an author declaration is not mistaken for a compatibility check. diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md index 2c40facd25..8a2399a19a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md @@ -12,6 +12,8 @@ Status: implemented [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。 +作者元数据保留在 `package.json.dsh` 下:可选的 `manifestVersion` 标识格式 `1`,`categories` 承载自由发现标签,`engines.dsh` 以 SemVer 范围声明兼容的宿主版本。npm 包身份和 Node 要求沿用顶层字段。Categories 是可扩展标签,而非封闭的角色枚举,因此 `skills`、`tools` 等标签不限制包的组合。 + 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。 App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 @@ -29,3 +31,5 @@ App-boot 声明生产依赖,因为其发布的声明文件引用共享类型 作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。 编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。 + +当前安装器和加载器不强制检查 manifest 格式或宿主兼容性声明。纯类型包既不提供 SemVer 解析器,也不提供安装策略;其 README 记录此限制,避免将作者声明误认为兼容性检查。 diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 6a8cad4326..f25db6d863 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/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/util/package-manifest/README.md -README.md: 971df84d7db3205eb8be75ba895046d4151780d2 -README.zh.md: 8989cbe9bc29bbf8112d5465eac9806c211fa88d +README.md: 428ed05af9856d4a1a77be9c152130fa26464bb9 +README.zh.md: cdf6302b537a458c010f5cd8b3531131fc6a21ac diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 971df84d7d..428ed05af9 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -32,12 +32,25 @@ import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-ma const client: DshClientManifest = { platform: 'web' } const dsh: DshManifest = { + manifestVersion: 1, + categories: ['skills', 'tools'], + engines: { dsh: '0.1.5-alpha.1' }, bundle: { patch: './cordis.patch.yml' }, client, } ``` -`DshManifest` describes `bundle`, `profile`, `client`, `configTrees`, `sessionFormatMigration`, and `moduleFallback`, not the surrounding npm manifest. `moduleFallback` is launcher-generated metadata and is not an author configuration entry. TypeScript checks this object and erases `import type` during compilation; JSON files cannot import types, and this example does not write a `package.json`. See [`src/types.ts`](src/types.ts) for the declarations. +`DshManifest` describes `package.json.dsh`; npm fields such as `name`, `version`, and `engines.node` stay at the package root. TypeScript checks this object and erases `import type` during compilation; JSON files cannot import types, and this example does not write a `package.json`. + +All three author metadata fields are optional. Omitting them leaves the format version, tags, or compatible host versions undeclared; readers do not infer defaults. + +| Field | Meaning | +|---|---| +| `manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | +| `categories` | Free-form discovery tags such as `skills` and `tools`, with no fixed taxonomy or effect on plugin loading. | +| `engines.dsh` | Author-declared compatible DSH versions as a SemVer range. An exact prerelease version is valid; the `dsh` member is required when `engines` is present. | + +Composition and build declarations are defined in [`src/types.ts`](src/types.ts). `moduleFallback` is launcher-generated metadata and is not an author configuration entry. ----- @@ -73,6 +86,7 @@ Type declarations add no model input, so provider cache reuse is unaffected. - **Static typing only.** These declarations do not validate JSON, check file existence, or supply defaults. `configTrees` serves the experimental image packer, and `sessionFormatMigration` is discovered only for workspace migration packages; declaring them does not register external plugin behavior. +- **Compatibility is declarative.** Current installers and loaders do not enforce `manifestVersion` or `engines.dsh`; declaring a range does not reject incompatible hosts or validate SemVer syntax. ### Dev Note diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 8989cbe9bc..cdf6302b53 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -32,12 +32,25 @@ import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-ma const client: DshClientManifest = { platform: 'web' } const dsh: DshManifest = { + manifestVersion: 1, + categories: ['skills', 'tools'], + engines: { dsh: '0.1.5-alpha.1' }, bundle: { patch: './cordis.patch.yml' }, client, } ``` -`DshManifest` 描述 `bundle`、`profile`、`client`、`configTrees`、`sessionFormatMigration` 和 `moduleFallback`,不包含外层 npm manifest。`moduleFallback` 是启动器生成的元数据,不是作者配置项。TypeScript 检查该对象,并在编译时删除 `import type`;JSON 文件不能导入类型,此示例也不会写入 `package.json`。声明见 [`src/types.ts`](src/types.ts)。 +`DshManifest` 描述 `package.json.dsh`;`name`、`version`、`engines.node` 等 npm 字段仍位于包的顶层。TypeScript 检查该对象,并在编译时删除 `import type`;JSON 文件不能导入类型,此示例也不会写入 `package.json`。 + +这三个作者元数据字段均可选。省略时,格式版本、标签或兼容的宿主版本保持未声明状态;读取方不推断默认值。 + +| 字段 | 含义 | +|---|---| +| `manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | +| `categories` | `skills`、`tools` 等自由发现标签,无固定分类体系,不影响插件加载。 | +| `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围。可填写精确的预发布版本;存在 `engines` 时,必须填写 `dsh` 成员。 | + +组合与构建声明定义在 [`src/types.ts`](src/types.ts) 中。`moduleFallback` 是启动器生成的元数据,不是作者配置项。 ----- @@ -73,6 +86,7 @@ const dsh: DshManifest = { - **仅提供静态类型。** 这些声明不校验 JSON、不检查文件存在性,也不提供默认值。`configTrees` 服务于实验性镜像打包器,`sessionFormatMigration` 仅从工作区迁移包中发现;声明它们不会注册外部插件行为。 +- **兼容性仅作声明。** 当前安装器和加载器不强制检查 `manifestVersion` 或 `engines.dsh`;声明范围不会拒绝不兼容的宿主,也不会校验 SemVer 语法。 ### 开发备注 diff --git a/packages/util/package-manifest/src/index.ts b/packages/util/package-manifest/src/index.ts index 2ea0cfd07f..a13bb94274 100644 --- a/packages/util/package-manifest/src/index.ts +++ b/packages/util/package-manifest/src/index.ts @@ -7,6 +7,7 @@ export type { DshBundleManifest, DshClientManifest, DshConfigTreeDeclaration, + DshEnginesManifest, DshManifest, DshModuleFallbackManifest, DshProfileManifest, diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index 2c98c63dc3..0528f2c167 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -6,6 +6,12 @@ /** The `dsh` property of an npm manifest; a package may declare several roles. */ export interface DshManifest { + /** Manifest format version, independent of the npm package and Session format versions. */ + manifestVersion?: 1 + /** Free-form discovery tags, such as `skills` or `tools`; do not affect plugin loading. */ + categories?: string[] + /** Declared host compatibility; current readers do not enforce version ranges. */ + engines?: DshEnginesManifest /** Bundle metadata consumed by the profile launcher. */ bundle?: DshBundleManifest /** Profile metadata consumed by the profile launcher. */ @@ -23,6 +29,12 @@ export interface DshManifest { moduleFallback?: DshModuleFallbackManifest } +/** Host version requirements declared by the package author. */ +export interface DshEnginesManifest { + /** Compatible DSH versions as a SemVer range, including an exact version. */ + dsh: string +} + /** The configuration layer exported by a bundle package. */ export interface DshBundleManifest { /** Patch file path relative to the declaring package root. */ From 5c82aa9371124a3a07eed26c8d93d180513094b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 10 Sep 2026 10:28:47 +0800 Subject: [PATCH 13/41] refactor(manifest): separate package metadata from public DSH fields --- ...026-09-05-package-manifest-types.i18n.yaml | 4 +- .../2026-09-05-package-manifest-types.md | 6 +- .../2026-09-05-package-manifest-types.zh.md | 6 +- ...26-09-10-public-package-manifest.i18n.yaml | 6 ++ .../2026-09-10-public-package-manifest.md | 35 ++++++++ .../2026-09-10-public-package-manifest.zh.md | 35 ++++++++ package.json | 1 - packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 13 +-- .../webworker-packer/README.i18n.yaml | 4 +- .../experimental/webworker-packer/README.md | 2 +- .../webworker-packer/README.zh.md | 2 +- .../webworker-packer/package.json | 3 +- .../webworker-packer/src/repository.ts | 13 ++- .../webworker-packer/tsconfig.json | 3 - .../util/package-manifest/README.i18n.yaml | 4 +- packages/util/package-manifest/README.md | 40 +++++---- packages/util/package-manifest/README.zh.md | 40 +++++---- packages/util/package-manifest/src/index.ts | 4 +- packages/util/package-manifest/src/types.ts | 87 +++++++------------ pnpm-lock.yaml | 6 -- scripts/gen-session-format-catalog.ts | 25 +++++- 24 files changed, 205 insertions(+), 142 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml index a15fd90d6d..1e9e2e658d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.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-package-manifest-types.md -2026-09-05-package-manifest-types.md: 5e9834583a75a3fd4f1ec69379628c49d264e05b -2026-09-05-package-manifest-types.zh.md: 8a2399a19a097113d45f4915631d36ca30191dd5 +2026-09-05-package-manifest-types.md: dc018ba019028b942a17cd016c8670f2405c3bc7 +2026-09-05-package-manifest-types.zh.md: 8fd323bfe6058a6a062e3834a8630c0181a203fc diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md index 5e9834583a..dc018ba019 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md @@ -10,13 +10,11 @@ External packages need Harness manifest types without depending on boot or clien ## Decision -[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished. - -Author metadata stays under `package.json.dsh`: optional `manifestVersion` identifies format `1`, `categories` carries free-form discovery tags, and `engines.dsh` declares compatible host versions as a SemVer range. Npm package identity and Node requirements retain their top-level fields. Categories are extensible tags rather than a closed role enum, so labels such as `skills` and `tools` do not constrain package composition. +[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. The [public package metadata decision](2026-09-10-public-package-manifest.md) owns the public field set and the separation from internal tool metadata. Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local. -App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. +App-boot declares a production dependency because its published declarations reference the shared types. Client modules use a development dependency because their published APIs do not expose these types. Internal image-packer and Session catalog declarations stay with their readers. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md index 8a2399a19a..8fd323bfe6 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md @@ -10,13 +10,11 @@ Status: implemented ## 决策 -[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。 - -作者元数据保留在 `package.json.dsh` 下:可选的 `manifestVersion` 标识格式 `1`,`categories` 承载自由发现标签,`engines.dsh` 以 SemVer 范围声明兼容的宿主版本。npm 包身份和 Node 要求沿用顶层字段。Categories 是可扩展标签,而非封闭的角色枚举,因此 `skills`、`tools` 等标签不限制包的组合。 +[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。[公共包元数据决策](2026-09-10-public-package-manifest.zh.md) 拥有公共字段范围及其与内部工具元数据的划分。 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。 -App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 +App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块使用开发依赖,因为其发布 API 不暴露这些类型。内部镜像打包器和 Session 目录声明保留在各自读取方。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml new file mode 100644 index 0000000000..2dbaeb2625 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.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-10-public-package-manifest.md +2026-09-10-public-package-manifest.md: 222cdb58af580678136f269b60aa8271024dc8ca +2026-09-10-public-package-manifest.zh.md: c3659586f340dfb5bcfbbd0cc8266772207e63de diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md new file mode 100644 index 0000000000..222cdb58af --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md @@ -0,0 +1,35 @@ +# Agent Note: Public package manifest fields + +Status: implemented + +English | [中文](2026-09-10-public-package-manifest.zh.md) + +## Problem + +Plugin authors need npm identity, runtime requirements, and DSH declarations from one public import. Internal image-packaging, Session catalog, and generated proxy metadata do not define extension points for community plugins. Exposing those fields together makes internal mechanisms appear available to external authors. + +## Decision + +[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) describes the package.json fields DSH uses, with required `name` and `version`. Its optional `dsh` member uses `DshManifest` for public composition and author metadata. The type is a selected npm field set, not a complete package.json schema. App-boot adapts it with `Partial` for local profiles, which need no published identity. + +Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`; `dsh.categories` carries free-form discovery tags without restricting package composition. Format and DSH compatibility declarations are not enforced by current installers or loaders. + +The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active. + +Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy. + +## Alternatives considered + +**Keep internal metadata in the public declaration.** A workspace-only migration catalog and an experimental image packer cannot offer public plugin behavior merely because their metadata is discoverable. + +**Put DSH compatibility under `dsh.engines`.** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) places its host requirement in top-level `engines.vscode`. Top-level `engines.dsh` gives authors one location for runtime requirements; DSH still owns enforcement of its custom key. + +**Use peer dependencies as the sole host requirement.** Peer dependencies constrain installed npm packages, including the CLI package `@deepseek-ai/dsh`. They do not identify the currently running DSH process when plugins live in a separate profile project. + +**Parse every domain through one mandatory parser.** Existing readers consume different subsets and own different errors and defaults. Combining them would make a client reader validate unrelated profile declarations. The public types remain independent of filesystem access and parsing policy. + +## Consequences + +External authors gain a complete package-level declaration and a smaller DSH author API. Consumers of removed internal types must use their owning implementations. The packer and repository catalog no longer depend on the public declaration package; app-boot retains a production dependency because its published profile type references it. + +Compiler and built NodeNext import checks verify required package identity, partial profiles, top-level engine declarations, and the absence of internal fields from the public API. Existing profile, packer, and Session catalog tests retain coverage of their accepted files and malformed declarations. No Session format, plugin loading rule, or model-visible behavior changes. diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md new file mode 100644 index 0000000000..c3659586f3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 公共 package manifest 字段 + +Status: implemented + +[English](2026-09-10-public-package-manifest.md) | 中文 + +## 问题 + +插件作者需要从统一的公共导入路径获取 npm 身份、运行时要求和 DSH 声明。内部镜像打包、Session 目录和生成的代理元数据不定义社区插件扩展点。将这些字段一起暴露,会让外部作者误以为内部机制也可供使用。 + +## 决策 + +[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填。其可选的 `dsh` 成员使用 `DshManifest` 描述公共组合与作者元数据。该类型只选取所需 npm 字段,不是完整的 package.json schema(模式)。App-boot 通过 `Partial` 适配无需发布身份的本地 profile。 + +运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`;`dsh.categories` 承载自由发现标签,不限制包的组合。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。 + +镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。 + +各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。 + +## 考虑过的替代方案 + +**将内部元数据保留在公共声明中。** 仅限工作区的迁移目录和实验性镜像打包器,不会因为其元数据可被发现就提供公共插件行为。 + +**将 DSH 兼容性放在 `dsh.engines` 下。** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) 将宿主要求放在顶层 `engines.vscode`。顶层 `engines.dsh` 让作者在同一位置声明运行时要求;自定义键的检查仍由 DSH 负责。 + +**仅用 peer dependency 声明宿主要求。** Peer dependency 约束已安装的 npm 包,包括 CLI 包 `@deepseek-ai/dsh`。插件位于独立 profile 项目时,它们无法标识当前运行的 DSH 进程。 + +**通过统一的强制解析器解析所有领域。** 现有读取方消费不同字段子集,并各自拥有错误与默认值。合并它们会让客户端读取方校验无关的 profile 声明。公共类型保持独立于文件系统访问和解析策略。 + +## 后果 + +外部作者获得完整的包级声明和更小的 DSH 作者 API。已移除内部类型的消费方必须使用各自负责的实现。打包器与仓库目录不再依赖公共声明包;app-boot 保留生产依赖,因为其发布的 profile 类型引用该包。 + +编译器和构建后的 NodeNext 导入检查验证包身份必填、部分 profile、顶层 engine 声明,以及公共 API 不含内部字段。现有 profile、打包器和 Session 目录测试继续覆盖其接受的文件与畸形声明。Session 格式、插件加载规则和模型可见行为均不改变。 diff --git a/package.json b/package.json index a09fa3f997..6bf6dea6c9 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-package-manifest": "workspace:^", "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 3d4666ea0d..3d70dc1c39 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: df386b64089f962960b09538381e9d4b4905feb0 -README.zh.md: 5a7246c52ea5bcc8d5322d9fac4a87ec2e2ef4cc +README.md: a525440f20879314bd8d12b647830f2c541f59b3 +README.zh.md: d194f191374e50811d4831933bf8e8b2b831cd40 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index df386b6408..a525440f20 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -45,7 +45,7 @@ With that entry point, success looks like a running app with every plugin active ### Profiles -Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data. +Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data. A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile